6185 lines
235 KiB
Python
6185 lines
235 KiB
Python
"""Core PPTX assembly: create_pptx_with_native_svg."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import math
|
||
import mimetypes
|
||
import os
|
||
import posixpath
|
||
import random
|
||
import re
|
||
import shutil
|
||
import stat
|
||
import subprocess
|
||
import tempfile
|
||
import uuid
|
||
import zipfile
|
||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path, PureWindowsPath
|
||
from typing import Any
|
||
from xml.etree import ElementTree as ET
|
||
from xml.sax.saxutils import escape
|
||
|
||
from pptx import Presentation
|
||
from pptx.util import Emu
|
||
|
||
from pptx_transitions import (
|
||
MorphPairExpectation,
|
||
NATIVE_TRANSITIONS,
|
||
create_transition_xml,
|
||
normalize_transition_effect_request,
|
||
set_directory_use_timings,
|
||
validate_generated_transition_xml,
|
||
validate_pptx_morph_pairs,
|
||
validate_pptx_transition_package,
|
||
validate_seconds,
|
||
)
|
||
from pptx_animations import (
|
||
ANIMATION_TIMING_OPTION_FIELDS,
|
||
animation_seconds_to_milliseconds,
|
||
create_sequence_timing_xml,
|
||
normalize_animation_effect,
|
||
normalize_animation_effect_request,
|
||
normalize_animation_trigger,
|
||
pick_animation_effect,
|
||
validate_generated_animation_xml,
|
||
validate_pptx_animation_package,
|
||
)
|
||
from pptx_opc_validation import (
|
||
canonical_opc_part_path as _canonical_opc_part_path,
|
||
resolve_internal_opc_target as _resolve_internal_opc_target,
|
||
verify_internal_relationships,
|
||
)
|
||
from language_tags import normalize_language_tag
|
||
|
||
from ..animation_config import (
|
||
MorphPair,
|
||
animation_group_effect_entries,
|
||
resolve_morph_pairs,
|
||
resolve_slide_animation_config,
|
||
)
|
||
from ..drawingml.context import resolve_text_flow
|
||
from ..drawingml.converter import convert_svg_to_slide_shapes
|
||
from ..drawingml.theme_colors import (
|
||
ThemeColorSpec,
|
||
apply_theme_color_spec,
|
||
rewrite_chart_accent_colors,
|
||
)
|
||
from ..drawingml.theme_fonts import (
|
||
MasterTextStyleSpec,
|
||
ThemeFontSpec,
|
||
apply_master_text_style_spec,
|
||
apply_theme_font_spec,
|
||
)
|
||
from ..drawingml.utils import EMU_PER_PX
|
||
from ..semantic_markers import (
|
||
chrome_token_from_markers,
|
||
page_layout_name_from_svg,
|
||
)
|
||
from .dimensions import (
|
||
CANVAS_FORMATS,
|
||
resolve_svg_canvas,
|
||
)
|
||
from .media import (
|
||
PNG_RENDERER,
|
||
get_png_renderer_info, convert_svg_to_png, convert_svg_to_png_cached,
|
||
)
|
||
from .notes import (
|
||
markdown_to_plain_text,
|
||
create_notes_master_rels_xml,
|
||
create_notes_master_xml,
|
||
create_notes_slide_xml,
|
||
create_notes_slide_rels_xml,
|
||
)
|
||
from .narration import (
|
||
AUDIO_CONTENT_TYPES,
|
||
AUDIO_REL_TYPE,
|
||
AUDIO_MARKER_PNG_BYTES,
|
||
DEFAULT_NARRATION_START_FLOOR,
|
||
IMAGE_REL_TYPE,
|
||
MEDIA_REL_TYPE,
|
||
apply_recorded_timing,
|
||
inject_narration,
|
||
narration_lead_in_seconds,
|
||
next_shape_id,
|
||
probe_audio_duration,
|
||
)
|
||
from .slide_xml import (
|
||
create_slide_xml_with_svg, create_slide_rels_xml,
|
||
)
|
||
from .template_structure import (
|
||
NativeStructureContract,
|
||
OOXML_UINT32_MAX,
|
||
TEMPLATE_PLACEHOLDER_TYPES,
|
||
TemplateElementSpec,
|
||
TemplateSlideSpec,
|
||
TemplateStructureError,
|
||
flat_structure_metadata_errors,
|
||
is_proxy_placeholder,
|
||
match_native_placeholders,
|
||
parse_preserve_slides,
|
||
parse_template_slides,
|
||
template_placeholder_bindings,
|
||
)
|
||
from .template_validation import validate_pptx_template_package
|
||
|
||
SLIDE_LAYOUT_REL_TYPE = (
|
||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
|
||
)
|
||
SLIDE_REL_TYPE = (
|
||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
|
||
)
|
||
SLIDE_MASTER_REL_TYPE = (
|
||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
|
||
)
|
||
THEME_REL_TYPE = (
|
||
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
|
||
)
|
||
THEME_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.theme+xml"
|
||
PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
|
||
DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
|
||
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||
P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
|
||
|
||
for _prefix, _uri in (("p", PML_NS), ("a", DML_NS), ("r", REL_NS), ("p14", P14_NS)):
|
||
try:
|
||
ET.register_namespace(_prefix, _uri)
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PptxStructureContext:
|
||
"""Resolved base package structure reused when slide XML is regenerated."""
|
||
|
||
slide_layout_targets: dict[int, str]
|
||
slide_master_parts: dict[int, str]
|
||
|
||
def slide_layout_target(self, slide_num: int) -> str:
|
||
"""Return the slide layout target for a generated slide."""
|
||
try:
|
||
return self.slide_layout_targets[slide_num]
|
||
except KeyError as exc:
|
||
raise RuntimeError(
|
||
f"Missing slide layout relationship for generated slide {slide_num}"
|
||
) from exc
|
||
|
||
def slide_master_part(self, slide_num: int) -> str:
|
||
"""Return the slide master package part for a generated slide."""
|
||
try:
|
||
return self.slide_master_parts[slide_num]
|
||
except KeyError as exc:
|
||
raise RuntimeError(
|
||
f"Missing slide master relationship for generated slide {slide_num}"
|
||
) from exc
|
||
|
||
|
||
@dataclass
|
||
class _TemplateRuntimeSlide:
|
||
"""Parsed slide package state used by explicit Layout structure export."""
|
||
|
||
spec: TemplateSlideSpec
|
||
slide_path: Path
|
||
rels_path: Path
|
||
tree: ET.ElementTree
|
||
root: ET.Element
|
||
rels: dict[str, dict[str, str]]
|
||
shapes: dict[str, ET.Element]
|
||
shape_ids_by_svg_id: dict[str, list[str]]
|
||
|
||
|
||
def _relationship_attrs(elem: ET.Element) -> dict[str, str]:
|
||
return {key.rsplit("}", 1)[-1]: value for key, value in elem.attrib.items()}
|
||
|
||
|
||
def _resolve_package_target(source_part: str, target: str) -> str:
|
||
"""Resolve a relationship target relative to a package part path."""
|
||
return posixpath.normpath(posixpath.join(posixpath.dirname(source_part), target))
|
||
|
||
|
||
def _relationships_path_for_part(extract_dir: Path, part_name: str) -> Path:
|
||
"""Return the package relationship sidecar path for a part name."""
|
||
path = Path(part_name)
|
||
return extract_dir / path.parent / "_rels" / f"{path.name}.rels"
|
||
|
||
|
||
def _find_relationship_target(
|
||
rels_path: Path,
|
||
rel_type: str,
|
||
) -> str | None:
|
||
"""Find the first relationship target for a relationship type."""
|
||
if not rels_path.exists():
|
||
return None
|
||
root = ET.parse(rels_path).getroot()
|
||
for elem in root:
|
||
attrs = _relationship_attrs(elem)
|
||
if attrs.get("Type") == rel_type:
|
||
return attrs.get("Target")
|
||
return None
|
||
|
||
|
||
def _read_relationships(rels_path: Path) -> dict[str, dict[str, str]]:
|
||
"""Return relationship attributes keyed by rId."""
|
||
if not rels_path.exists():
|
||
return {}
|
||
root = ET.parse(rels_path).getroot()
|
||
rels: dict[str, dict[str, str]] = {}
|
||
for elem in root:
|
||
attrs = _relationship_attrs(elem)
|
||
rel_id = attrs.get("Id")
|
||
if rel_id:
|
||
rels[rel_id] = attrs
|
||
return rels
|
||
|
||
|
||
def _find_relationship_id(
|
||
rels_path: Path,
|
||
rel_type: str,
|
||
target: str,
|
||
) -> str | None:
|
||
"""Find an existing relationship by type and target."""
|
||
for rel_id, attrs in _read_relationships(rels_path).items():
|
||
if attrs.get("Type") == rel_type and attrs.get("Target") == target:
|
||
return rel_id
|
||
return None
|
||
|
||
|
||
def _read_slide_layout_targets(extract_dir: Path, slide_count: int) -> PptxStructureContext:
|
||
"""Read the actual layout relationship target for every generated slide."""
|
||
slide_layout_targets: dict[int, str] = {}
|
||
slide_master_parts: dict[int, str] = {}
|
||
rels_dir = extract_dir / "ppt" / "slides" / "_rels"
|
||
for slide_num in range(1, slide_count + 1):
|
||
rels_path = rels_dir / f"slide{slide_num}.xml.rels"
|
||
if not rels_path.exists():
|
||
raise RuntimeError(f"Missing slide relationship file: {rels_path}")
|
||
target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
|
||
if not target:
|
||
raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
|
||
slide_layout_targets[slide_num] = target
|
||
|
||
slide_part = f"ppt/slides/slide{slide_num}.xml"
|
||
layout_part = _resolve_package_target(slide_part, target)
|
||
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
|
||
master_target = _find_relationship_target(layout_rels_path, SLIDE_MASTER_REL_TYPE)
|
||
if not master_target:
|
||
raise RuntimeError(
|
||
f"Slide {slide_num} layout has no slide master relationship"
|
||
)
|
||
slide_master_parts[slide_num] = _resolve_package_target(layout_part, master_target)
|
||
return PptxStructureContext(
|
||
slide_layout_targets=slide_layout_targets,
|
||
slide_master_parts=slide_master_parts,
|
||
)
|
||
|
||
|
||
_SLIDE_BACKGROUND_RE = re.compile(
|
||
r"(?P<prefix><p:cSld\b[^>]*>\s*)"
|
||
r"(?P<bg><p:bg\b.*?</p:bg>)"
|
||
r"(?P<suffix>\s*<p:spTree\b)",
|
||
re.DOTALL,
|
||
)
|
||
|
||
|
||
def _extract_slide_background_xml(slide_xml: str) -> str | None:
|
||
"""Return the slide-level p:bg XML when it directly precedes spTree."""
|
||
match = _SLIDE_BACKGROUND_RE.search(slide_xml)
|
||
return match.group("bg") if match else None
|
||
|
||
|
||
def _remove_slide_background_xml(slide_xml: str) -> str:
|
||
"""Remove a promoted slide-level p:bg from cSld."""
|
||
return _SLIDE_BACKGROUND_RE.sub(r"\g<prefix>\g<suffix>", slide_xml, count=1)
|
||
|
||
|
||
def _put_background_on_part(part_xml: str, background_xml: str) -> str | None:
|
||
"""Replace or insert p:bg before a slide/master/layout spTree.
|
||
|
||
Returns None when the part carries a p:bg the canonical pattern cannot
|
||
replace; inserting there would leave two p:bg children under p:cSld.
|
||
"""
|
||
match = _SLIDE_BACKGROUND_RE.search(part_xml)
|
||
if match:
|
||
return (
|
||
part_xml[:match.start("bg")]
|
||
+ background_xml
|
||
+ part_xml[match.end("bg"):]
|
||
)
|
||
if "<p:bg" in part_xml:
|
||
return None
|
||
|
||
cslide_match = re.search(r"(<p:cSld\b[^>]*>)", part_xml)
|
||
if not cslide_match:
|
||
raise RuntimeError("PPTX slide/master/layout part has no p:cSld element")
|
||
return (
|
||
part_xml[:cslide_match.end()]
|
||
+ background_xml
|
||
+ part_xml[cslide_match.end():]
|
||
)
|
||
|
||
|
||
def _dominant_variant(
|
||
values_by_slide: dict[int, Any],
|
||
) -> tuple[Any | None, list[int]]:
|
||
"""Return the most common value and its slides, or None on a tie."""
|
||
slides_by_value: dict[Any, list[int]] = {}
|
||
for slide_num, value in sorted(values_by_slide.items()):
|
||
slides_by_value.setdefault(value, []).append(slide_num)
|
||
if not slides_by_value:
|
||
return None, []
|
||
best_count = max(len(slides) for slides in slides_by_value.values())
|
||
dominant = [
|
||
(value, slides)
|
||
for value, slides in slides_by_value.items()
|
||
if len(slides) == best_count
|
||
]
|
||
if len(dominant) != 1:
|
||
return None, []
|
||
return dominant[0]
|
||
|
||
|
||
def _is_strict_majority(subset_size: int, total: int) -> bool:
|
||
return subset_size >= 2 and subset_size * 2 > total
|
||
|
||
|
||
def _promote_common_slide_backgrounds_to_masters(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
slide_count: int,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Promote the majority slide background to its shared slide master.
|
||
|
||
Every slide in the master group must carry an explicit background —
|
||
a slide without one would start inheriting the promoted master fill.
|
||
Minority slides keep their own slide-level background, which always
|
||
overrides the master fill.
|
||
"""
|
||
slides_by_master: dict[str, list[int]] = {}
|
||
for slide_num in range(1, slide_count + 1):
|
||
master_part = structure.slide_master_part(slide_num)
|
||
slides_by_master.setdefault(master_part, []).append(slide_num)
|
||
|
||
promoted = 0
|
||
for master_part, slide_nums in slides_by_master.items():
|
||
slide_backgrounds: dict[int, str] = {}
|
||
for slide_num in slide_nums:
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
slide_xml = slide_path.read_text(encoding="utf-8")
|
||
background_xml = _extract_slide_background_xml(slide_xml)
|
||
if not background_xml:
|
||
slide_backgrounds = {}
|
||
break
|
||
slide_backgrounds[slide_num] = background_xml
|
||
|
||
if not slide_backgrounds:
|
||
continue
|
||
background_xml, dominant_slides = _dominant_variant(slide_backgrounds)
|
||
if background_xml is None:
|
||
continue
|
||
if not _is_strict_majority(len(dominant_slides), len(slide_nums)):
|
||
continue
|
||
|
||
master_path = extract_dir / master_part
|
||
master_xml = master_path.read_text(encoding="utf-8")
|
||
promoted_master_xml = _put_background_on_part(master_xml, background_xml)
|
||
if promoted_master_xml is None:
|
||
continue
|
||
master_path.write_text(promoted_master_xml, encoding="utf-8")
|
||
|
||
for slide_num in dominant_slides:
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
slide_xml = slide_path.read_text(encoding="utf-8")
|
||
slide_path.write_text(
|
||
_remove_slide_background_xml(slide_xml),
|
||
encoding="utf-8",
|
||
)
|
||
promoted += 1
|
||
|
||
if verbose and promoted:
|
||
print(f" Baseline master background: promoted {promoted} slide background(s)")
|
||
return promoted
|
||
|
||
|
||
_CHROME_TRACE_TOKENS = (
|
||
"logo",
|
||
"footer",
|
||
"header",
|
||
"watermark",
|
||
"chrome",
|
||
"pagenumber",
|
||
"slidenumber",
|
||
"pagenum",
|
||
"slidenum",
|
||
)
|
||
_TOP_LEVEL_SHAPE_TAGS = {
|
||
f"{{{PML_NS}}}sp",
|
||
f"{{{PML_NS}}}grpSp",
|
||
f"{{{PML_NS}}}pic",
|
||
f"{{{PML_NS}}}cxnSp",
|
||
f"{{{PML_NS}}}graphicFrame",
|
||
}
|
||
_FLAT_SYSTEM_PLACEHOLDER_TYPES = frozenset({"dt", "ftr", "sldNum"})
|
||
_REL_ATTRS = {
|
||
f"{{{REL_NS}}}embed",
|
||
f"{{{REL_NS}}}link",
|
||
f"{{{REL_NS}}}id",
|
||
}
|
||
|
||
|
||
def _chrome_token_from_svg_id(svg_id: str | None) -> str | None:
|
||
"""Return the baseline chrome token encoded in a source SVG id."""
|
||
if not svg_id:
|
||
return None
|
||
lower = svg_id.lower()
|
||
compact = re.sub(r"[-_\s]+", "", lower)
|
||
if compact in _CHROME_TRACE_TOKENS:
|
||
return compact
|
||
split_tokens = {token for token in re.split(r"[-_\s]+", lower) if token}
|
||
for token in _CHROME_TRACE_TOKENS:
|
||
if token in split_tokens:
|
||
return token
|
||
return None
|
||
|
||
|
||
def _trace_chrome_shape_ids(
|
||
trace: dict[str, Any] | None,
|
||
) -> dict[str, list[str]]:
|
||
"""Map chrome token to generated top-level shape ids for one slide."""
|
||
result: dict[str, list[str]] = {}
|
||
if not trace:
|
||
return result
|
||
for event in trace.get("events", []):
|
||
if event.get("decision") != "native":
|
||
continue
|
||
semantic_role = event.get("data-pptx-role")
|
||
placeholder = event.get("data-pptx-placeholder")
|
||
has_explicit_semantics = (
|
||
semantic_role is not None or placeholder is not None
|
||
)
|
||
token = (
|
||
chrome_token_from_markers(semantic_role, placeholder)
|
||
if has_explicit_semantics
|
||
else _chrome_token_from_svg_id(event.get("id"))
|
||
)
|
||
shape_id = event.get("shape_id")
|
||
if token and shape_id is not None:
|
||
shape_ids = result.setdefault(token, [])
|
||
normalized_shape_id = str(shape_id)
|
||
if normalized_shape_id not in shape_ids:
|
||
shape_ids.append(normalized_shape_id)
|
||
return result
|
||
|
||
|
||
def _trace_native_shape_ids(
|
||
trace: dict[str, Any] | None,
|
||
) -> dict[str, list[str]]:
|
||
"""Map every traced SVG id to generated top-level shape ids."""
|
||
result: dict[str, list[str]] = {}
|
||
if not trace:
|
||
return result
|
||
for event in trace.get("events", []):
|
||
if event.get("decision") != "native":
|
||
continue
|
||
svg_id = event.get("id")
|
||
shape_id = event.get("shape_id")
|
||
if not svg_id or shape_id is None:
|
||
continue
|
||
shape_ids = result.setdefault(str(svg_id), [])
|
||
normalized = str(shape_id)
|
||
if normalized not in shape_ids:
|
||
shape_ids.append(normalized)
|
||
return result
|
||
|
||
|
||
def _shape_id(elem: ET.Element) -> str | None:
|
||
for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
|
||
return cnv.attrib.get("id")
|
||
return None
|
||
|
||
|
||
def _set_shape_name(elem: ET.Element, name: str) -> None:
|
||
"""Give one top-level shape a deterministic read-back identity."""
|
||
for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
|
||
cnv.set("name", name)
|
||
return
|
||
raise TemplateStructureError(
|
||
f"Cannot name structured shape {name!r}: p:cNvPr is missing"
|
||
)
|
||
|
||
|
||
def _apply_morph_shape_names(
|
||
extract_dir: Path,
|
||
pairs: tuple[MorphPair, ...],
|
||
slide_numbers: dict[str, int],
|
||
shape_ids: dict[tuple[str, str], int],
|
||
) -> dict[int, dict[str, str]]:
|
||
"""Write forced-Morph names after all structure transformations finish."""
|
||
assignments: dict[int, dict[str, tuple[str, str]]] = {}
|
||
names_by_slide: dict[int, dict[str, str]] = {}
|
||
for pair in pairs:
|
||
for slide_name, group_id in (
|
||
(pair.source_slide, pair.source_group_id),
|
||
(pair.destination_slide, pair.destination_group_id),
|
||
):
|
||
slide_number = slide_numbers[slide_name]
|
||
shape_id = str(shape_ids[(slide_name, group_id)])
|
||
slide_assignments = assignments.setdefault(slide_number, {})
|
||
previous = slide_assignments.setdefault(
|
||
shape_id,
|
||
(pair.shape_name, group_id),
|
||
)
|
||
if previous[0] != pair.shape_name:
|
||
raise RuntimeError(
|
||
f'Morph target "{slide_name}/{group_id}" resolves to shape '
|
||
f'{shape_id} with conflicting names "{previous[0]}" and '
|
||
f'"{pair.shape_name}"'
|
||
)
|
||
slide_names = names_by_slide.setdefault(slide_number, {})
|
||
previous_group = slide_names.setdefault(pair.shape_name, group_id)
|
||
if previous_group != group_id:
|
||
raise RuntimeError(
|
||
f'Morph name "{pair.shape_name}" maps to multiple objects '
|
||
f'on slide "{slide_name}"'
|
||
)
|
||
|
||
trace_names: dict[int, dict[str, str]] = {}
|
||
for slide_number, slide_assignments in sorted(assignments.items()):
|
||
slide_path = (
|
||
extract_dir / "ppt" / "slides" / f"slide{slide_number}.xml"
|
||
)
|
||
tree = ET.parse(slide_path)
|
||
root = tree.getroot()
|
||
top_level_shapes = _top_level_shapes_by_id(root)
|
||
desired_names = {
|
||
shape_name
|
||
for shape_name, _group_id in slide_assignments.values()
|
||
}
|
||
for shape_id, shape in top_level_shapes.items():
|
||
if shape_id in slide_assignments:
|
||
continue
|
||
c_nv_pr = next(shape.iter(f"{{{PML_NS}}}cNvPr"), None)
|
||
existing_name = (
|
||
c_nv_pr.get("name") if c_nv_pr is not None else None
|
||
)
|
||
if existing_name in desired_names:
|
||
raise RuntimeError(
|
||
f'Morph name "{existing_name}" already belongs to an '
|
||
f'unmapped object on slide {slide_number}'
|
||
)
|
||
|
||
for shape_id, (shape_name, group_id) in slide_assignments.items():
|
||
shape = top_level_shapes.get(shape_id)
|
||
if shape is None:
|
||
raise RuntimeError(
|
||
f'Morph target "{group_id}" no longer resolves to a '
|
||
f'Slide-local shape on slide {slide_number}'
|
||
)
|
||
_set_shape_name(shape, shape_name)
|
||
trace_names.setdefault(slide_number, {})[group_id] = shape_name
|
||
_write_xml_tree(slide_path, tree)
|
||
return trace_names
|
||
|
||
|
||
def _top_level_shape_name_roster(root: ET.Element) -> tuple[str, ...]:
|
||
"""Return the exact visible top-level shape-name sequence for read-back."""
|
||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
raise TemplateStructureError("Structured part has no p:cSld/p:spTree")
|
||
names: list[str] = []
|
||
for child in sp_tree:
|
||
if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
|
||
continue
|
||
c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
|
||
name = c_nv_pr.get("name") if c_nv_pr is not None else None
|
||
if not name:
|
||
raise TemplateStructureError(
|
||
"Structured part contains a top-level shape without a name"
|
||
)
|
||
names.append(name)
|
||
return tuple(names)
|
||
|
||
|
||
def _top_level_shapes_by_id(root: ET.Element) -> dict[str, ET.Element]:
|
||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
return {}
|
||
shapes: dict[str, ET.Element] = {}
|
||
for child in list(sp_tree):
|
||
if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
|
||
continue
|
||
shape_id = _shape_id(child)
|
||
if shape_id:
|
||
shapes[shape_id] = child
|
||
return shapes
|
||
|
||
|
||
def _timing_shape_ids(root: ET.Element) -> set[str]:
|
||
"""Return slide-local shape ids referenced by animation timing."""
|
||
return {
|
||
elem.attrib["spid"]
|
||
for elem in root.findall(f".//{{{PML_NS}}}timing//{{{PML_NS}}}spTgt")
|
||
if elem.attrib.get("spid")
|
||
}
|
||
|
||
|
||
def _relationship_ids_in_shape(elem: ET.Element) -> set[str]:
|
||
rel_ids: set[str] = set()
|
||
for node in elem.iter():
|
||
for attr_name, value in node.attrib.items():
|
||
if attr_name in _REL_ATTRS and value:
|
||
rel_ids.add(value)
|
||
return rel_ids
|
||
|
||
|
||
def _shape_relationships_supported(
|
||
elem: ET.Element,
|
||
rels: dict[str, dict[str, str]],
|
||
) -> bool:
|
||
"""Only image relationships are safe to copy into a slide master here."""
|
||
for rel_id in _relationship_ids_in_shape(elem):
|
||
attrs = rels.get(rel_id)
|
||
if not attrs:
|
||
return False
|
||
if attrs.get("TargetMode"):
|
||
return False
|
||
if attrs.get("Type") != IMAGE_REL_TYPE:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _canonical_shape_xml(
|
||
elem: ET.Element,
|
||
rels: dict[str, dict[str, str]],
|
||
) -> bytes:
|
||
"""Canonicalize ids and relationship ids for cross-slide equality."""
|
||
clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
|
||
for cnv in clone.iter(f"{{{PML_NS}}}cNvPr"):
|
||
cnv.set("id", "ID")
|
||
# Generated names include the slide-local shape id (for example,
|
||
# ``Image 2`` versus ``Image 8``) but do not affect rendering.
|
||
if "name" in cnv.attrib:
|
||
cnv.set("name", "NAME")
|
||
for fld in clone.iter(f"{{{DML_NS}}}fld"):
|
||
# The literal inside a slide-number field is a per-slide render
|
||
# cache that PowerPoint recomputes from the slide position.
|
||
if fld.attrib.get("type") == "slidenum":
|
||
cached = fld.find(f"{{{DML_NS}}}t")
|
||
if cached is not None:
|
||
cached.text = ""
|
||
for node in clone.iter():
|
||
for attr_name, value in list(node.attrib.items()):
|
||
if attr_name not in _REL_ATTRS:
|
||
continue
|
||
attrs = rels.get(value, {})
|
||
node.set(
|
||
attr_name,
|
||
f"{attrs.get('Type', '')}|{attrs.get('Target', '')}",
|
||
)
|
||
return ET.tostring(clone, encoding="utf-8")
|
||
|
||
|
||
def _ensure_relationship(
|
||
rels_path: Path,
|
||
rel_type: str,
|
||
target: str,
|
||
) -> str:
|
||
existing = _find_relationship_id(rels_path, rel_type, target)
|
||
if existing:
|
||
return existing
|
||
return _append_relationship(rels_path, rel_type, target)
|
||
|
||
|
||
def _copy_shape_relationships_to_part(
|
||
elem: ET.Element,
|
||
slide_rels: dict[str, dict[str, str]],
|
||
target_rels_path: Path,
|
||
) -> ET.Element:
|
||
"""Clone a shape and retarget supported relationship ids to another part."""
|
||
clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
|
||
for node in clone.iter():
|
||
for attr_name, value in list(node.attrib.items()):
|
||
if attr_name not in _REL_ATTRS:
|
||
continue
|
||
rel = slide_rels.get(value)
|
||
if not rel:
|
||
raise RuntimeError(f"Missing slide relationship for {value}")
|
||
new_rid = _ensure_relationship(
|
||
target_rels_path,
|
||
rel["Type"],
|
||
rel["Target"],
|
||
)
|
||
node.set(attr_name, new_rid)
|
||
return clone
|
||
|
||
|
||
def _copy_shape_relationships_to_master(
|
||
elem: ET.Element,
|
||
slide_rels: dict[str, dict[str, str]],
|
||
master_rels_path: Path,
|
||
) -> ET.Element:
|
||
"""Clone a shape and retarget supported relationship ids to the master."""
|
||
return _copy_shape_relationships_to_part(elem, slide_rels, master_rels_path)
|
||
|
||
|
||
def _next_master_shape_id(master_xml: str) -> int:
|
||
ids = [
|
||
int(match)
|
||
for match in re.findall(r"<p:cNvPr\b[^>]*\bid=\"(\d+)\"", master_xml)
|
||
]
|
||
return max(ids, default=1) + 1
|
||
|
||
|
||
def _renumber_shape_ids(elem: ET.Element, start_id: int) -> None:
|
||
next_id = start_id
|
||
for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
|
||
cnv.set("id", str(next_id))
|
||
next_id += 1
|
||
|
||
|
||
def _append_shape_to_master(master_path: Path, elem: ET.Element) -> None:
|
||
master_xml = master_path.read_text(encoding="utf-8")
|
||
_renumber_shape_ids(elem, _next_master_shape_id(master_xml))
|
||
shape_xml = ET.tostring(elem, encoding="unicode")
|
||
if "</p:spTree>" not in master_xml:
|
||
raise RuntimeError(f"Slide master has no p:spTree: {master_path}")
|
||
master_path.write_text(
|
||
master_xml.replace("</p:spTree>", f"{shape_xml}\n</p:spTree>", 1),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def _append_shape_to_part(part_path: Path, elem: ET.Element) -> None:
|
||
"""Append a top-level shape to a master/layout spTree with fresh ids."""
|
||
tree = ET.parse(part_path)
|
||
root = tree.getroot()
|
||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
raise RuntimeError(f"PPTX part has no p:spTree: {part_path}")
|
||
existing_ids = [
|
||
int(cnv.attrib["id"])
|
||
for cnv in root.iter(f"{{{PML_NS}}}cNvPr")
|
||
if cnv.attrib.get("id", "").isdigit()
|
||
]
|
||
clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
|
||
_renumber_shape_ids(clone, max(existing_ids, default=1) + 1)
|
||
sp_tree.append(clone)
|
||
_write_xml_tree(part_path, tree)
|
||
|
||
|
||
def _write_xml_tree(path: Path, tree: ET.ElementTree) -> None:
|
||
tree.write(path, encoding="utf-8", xml_declaration=True)
|
||
|
||
|
||
COVER_LAYOUT_NAME = "Cover"
|
||
|
||
|
||
def _next_layout_part_number(extract_dir: Path) -> int:
|
||
layouts_dir = extract_dir / "ppt" / "slideLayouts"
|
||
numbers = [
|
||
int(match.group(1))
|
||
for path in layouts_dir.glob("slideLayout*.xml")
|
||
if (match := re.fullmatch(r"slideLayout(\d+)\.xml", path.name))
|
||
]
|
||
return max(numbers, default=0) + 1
|
||
|
||
|
||
def _next_slide_layout_id(extract_dir: Path) -> int:
|
||
"""Return a package-wide unused id for a new sldLayoutId entry."""
|
||
ids: list[int] = []
|
||
masters_dir = extract_dir / "ppt" / "slideMasters"
|
||
for master_path in sorted(masters_dir.glob("slideMaster*.xml")):
|
||
master_root = ET.parse(master_path).getroot()
|
||
ids.extend(
|
||
int(entry.attrib["id"])
|
||
for entry in master_root.findall(
|
||
f"{{{PML_NS}}}sldLayoutIdLst/{{{PML_NS}}}sldLayoutId"
|
||
)
|
||
if entry.attrib.get("id", "").isdigit()
|
||
)
|
||
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
||
if presentation_path.exists():
|
||
ids.extend(
|
||
int(value)
|
||
for value in re.findall(
|
||
r'\bid="(\d{9,})"', presentation_path.read_text(encoding="utf-8")
|
||
)
|
||
)
|
||
next_id = max([*ids, 2147483648]) + 1
|
||
if next_id > OOXML_UINT32_MAX:
|
||
raise TemplateStructureError(
|
||
"Cannot register another Slide Layout because the OOXML UInt32 "
|
||
"identifier range is exhausted"
|
||
)
|
||
return next_id
|
||
|
||
|
||
def _create_cover_layout(extract_dir: Path, master_part: str, base_layout_part: str) -> str:
|
||
"""Clone a layout into a Cover layout that hides master shapes.
|
||
|
||
Returns the new layout target relative to slide parts.
|
||
"""
|
||
base_layout_path = extract_dir / base_layout_part
|
||
layout_xml = base_layout_path.read_text(encoding="utf-8")
|
||
|
||
root_match = re.search(r"<p:sldLayout\b[^>]*>", layout_xml)
|
||
if not root_match:
|
||
raise RuntimeError(f"Slide layout has no p:sldLayout root: {base_layout_part}")
|
||
root_tag = root_match.group(0)
|
||
if "showMasterSp=" in root_tag:
|
||
new_root_tag = re.sub(r'showMasterSp="[^"]*"', 'showMasterSp="0"', root_tag)
|
||
else:
|
||
new_root_tag = root_tag[:-1] + ' showMasterSp="0">'
|
||
layout_xml = layout_xml.replace(root_tag, new_root_tag, 1)
|
||
layout_xml = re.sub(
|
||
r"(<p:cSld\b[^>]*?)\s+name=\"[^\"]*\"",
|
||
rf'\g<1> name="{COVER_LAYOUT_NAME}"',
|
||
layout_xml,
|
||
count=1,
|
||
)
|
||
|
||
layout_num = _next_layout_part_number(extract_dir)
|
||
new_layout_part = f"ppt/slideLayouts/slideLayout{layout_num}.xml"
|
||
new_layout_path = extract_dir / new_layout_part
|
||
new_layout_path.write_text(layout_xml, encoding="utf-8")
|
||
|
||
base_rels_path = _relationships_path_for_part(extract_dir, base_layout_part)
|
||
new_rels_path = _relationships_path_for_part(extract_dir, new_layout_part)
|
||
new_rels_path.parent.mkdir(exist_ok=True)
|
||
new_rels_path.write_text(
|
||
base_rels_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||
)
|
||
|
||
master_path = extract_dir / master_part
|
||
master_rels_path = _relationships_path_for_part(extract_dir, master_part)
|
||
layout_target = posixpath.relpath(
|
||
new_layout_part, posixpath.dirname(master_part)
|
||
)
|
||
rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target)
|
||
|
||
master_xml = master_path.read_text(encoding="utf-8")
|
||
layout_id = _next_slide_layout_id(extract_dir)
|
||
entry = f'<p:sldLayoutId id="{layout_id}" r:id="{rel_id}"/>'
|
||
if "</p:sldLayoutIdLst>" not in master_xml:
|
||
raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}")
|
||
master_path.write_text(
|
||
master_xml.replace("</p:sldLayoutIdLst>", f"{entry}</p:sldLayoutIdLst>", 1),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
content_types_path.write_text(
|
||
_add_content_type_override(
|
||
content_types_path.read_text(encoding="utf-8"),
|
||
new_layout_part,
|
||
"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return posixpath.relpath(new_layout_part, "ppt/slides")
|
||
|
||
|
||
def _create_custom_layout(
|
||
extract_dir: Path,
|
||
master_part: str,
|
||
base_layout_part: str,
|
||
layout_name: str,
|
||
*,
|
||
show_master_shapes: bool = True,
|
||
) -> tuple[str, str]:
|
||
"""Clone a clean custom layout and register it under its slide master.
|
||
|
||
Returns ``(slide_relationship_target, package_part)``.
|
||
"""
|
||
base_layout_path = extract_dir / base_layout_part
|
||
tree = ET.parse(base_layout_path)
|
||
root = tree.getroot()
|
||
_reseed_p14_creation_id(root)
|
||
root.set("type", "cust")
|
||
root.set("preserve", "1")
|
||
root.set("showMasterSp", "1" if show_master_shapes else "0")
|
||
|
||
c_sld = root.find(f"{{{PML_NS}}}cSld")
|
||
if c_sld is None:
|
||
raise RuntimeError(f"Slide layout has no p:cSld: {base_layout_part}")
|
||
c_sld.set("name", layout_name)
|
||
sp_tree = c_sld.find(f"{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
raise RuntimeError(f"Slide layout has no p:spTree: {base_layout_part}")
|
||
for child in list(sp_tree):
|
||
if child.tag in _TOP_LEVEL_SHAPE_TAGS:
|
||
sp_tree.remove(child)
|
||
|
||
layout_num = _next_layout_part_number(extract_dir)
|
||
new_layout_part = f"ppt/slideLayouts/slideLayout{layout_num}.xml"
|
||
new_layout_path = extract_dir / new_layout_part
|
||
_write_xml_tree(new_layout_path, tree)
|
||
|
||
base_rels_path = _relationships_path_for_part(extract_dir, base_layout_part)
|
||
new_rels_path = _relationships_path_for_part(extract_dir, new_layout_part)
|
||
new_rels_path.parent.mkdir(exist_ok=True)
|
||
new_rels_path.write_text(
|
||
base_rels_path.read_text(encoding="utf-8"),
|
||
encoding="utf-8",
|
||
)
|
||
master_target = posixpath.relpath(
|
||
master_part,
|
||
posixpath.dirname(new_layout_part),
|
||
)
|
||
rels_content = new_rels_path.read_text(encoding="utf-8")
|
||
master_rel_ids = [
|
||
rel_id
|
||
for rel_id, attrs in _read_relationships(new_rels_path).items()
|
||
if attrs.get("Type") == SLIDE_MASTER_REL_TYPE
|
||
]
|
||
if len(master_rel_ids) != 1:
|
||
raise RuntimeError(
|
||
f"Cloned slide layout must have one Master relationship: {new_layout_part}"
|
||
)
|
||
master_rel_id = master_rel_ids[0]
|
||
master_rel_pattern = re.compile(
|
||
rf'(<Relationship\b[^>]*\bId="{re.escape(master_rel_id)}"'
|
||
rf'[^>]*\bTarget=")[^"]*(")'
|
||
)
|
||
rels_content, replaced = master_rel_pattern.subn(
|
||
rf"\g<1>{master_target}\g<2>",
|
||
rels_content,
|
||
count=1,
|
||
)
|
||
if replaced != 1:
|
||
raise RuntimeError(
|
||
f"Could not retarget cloned Layout to Master {master_part}"
|
||
)
|
||
new_rels_path.write_text(rels_content, encoding="utf-8")
|
||
|
||
master_path = extract_dir / master_part
|
||
master_rels_path = _relationships_path_for_part(extract_dir, master_part)
|
||
layout_target = posixpath.relpath(new_layout_part, posixpath.dirname(master_part))
|
||
rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target)
|
||
master_tree = ET.parse(master_path)
|
||
master_root = master_tree.getroot()
|
||
layout_list = master_root.find(f"{{{PML_NS}}}sldLayoutIdLst")
|
||
if layout_list is None:
|
||
raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}")
|
||
layout_id = _next_slide_layout_id(extract_dir)
|
||
ET.SubElement(
|
||
layout_list,
|
||
f"{{{PML_NS}}}sldLayoutId",
|
||
{"id": str(layout_id), f"{{{REL_NS}}}id": rel_id},
|
||
)
|
||
_write_xml_tree(master_path, master_tree)
|
||
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
content_types_path.write_text(
|
||
_add_content_type_override(
|
||
content_types_path.read_text(encoding="utf-8"),
|
||
new_layout_part,
|
||
"application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return posixpath.relpath(new_layout_part, "ppt/slides"), new_layout_part
|
||
|
||
|
||
def _set_master_picker_name(master_path: Path, master_name: str) -> None:
|
||
"""Set the visible PowerPoint Master name on its common slide data."""
|
||
tree = ET.parse(master_path)
|
||
c_sld = tree.getroot().find(f"{{{PML_NS}}}cSld")
|
||
if c_sld is None:
|
||
raise RuntimeError(f"Slide master has no p:cSld: {master_path}")
|
||
c_sld.set("name", master_name)
|
||
_write_xml_tree(master_path, tree)
|
||
|
||
|
||
def _reseed_p14_creation_id(root: ET.Element) -> None:
|
||
"""Give a cloned Slide/Master/Layout part a fresh PowerPoint creation id."""
|
||
c_sld = root.find(f"{{{PML_NS}}}cSld")
|
||
if c_sld is None:
|
||
return
|
||
creation_ids = c_sld.findall(
|
||
f"{{{PML_NS}}}extLst/{{{PML_NS}}}ext/"
|
||
f"{{{P14_NS}}}creationId"
|
||
)
|
||
for creation_id in creation_ids:
|
||
value = 0
|
||
while value == 0:
|
||
value = uuid.uuid4().int & OOXML_UINT32_MAX
|
||
creation_id.set("val", str(value))
|
||
|
||
|
||
def _next_master_part_number(extract_dir: Path) -> int:
|
||
numbers = [
|
||
int(match.group(1))
|
||
for path in (extract_dir / "ppt" / "slideMasters").glob("slideMaster*.xml")
|
||
if (match := re.fullmatch(r"slideMaster(\d+)\.xml", path.name))
|
||
]
|
||
return max(numbers, default=0) + 1
|
||
|
||
|
||
def _next_theme_part_number(extract_dir: Path) -> int:
|
||
numbers = [
|
||
int(match.group(1))
|
||
for path in (extract_dir / "ppt" / "theme").glob("theme*.xml")
|
||
if (match := re.fullmatch(r"theme(\d+)\.xml", path.name))
|
||
]
|
||
return max(numbers, default=0) + 1
|
||
|
||
|
||
def _clone_master_theme(
|
||
extract_dir: Path,
|
||
source_master_part: str,
|
||
master_part: str,
|
||
master_name: str,
|
||
) -> str:
|
||
"""Give a cloned Slide Master its own Theme package part."""
|
||
source_master_rels = _relationships_path_for_part(
|
||
extract_dir,
|
||
source_master_part,
|
||
)
|
||
theme_targets = [
|
||
attrs["Target"]
|
||
for attrs in _read_relationships(source_master_rels).values()
|
||
if attrs.get("Type") == THEME_REL_TYPE and attrs.get("Target")
|
||
]
|
||
if len(theme_targets) != 1:
|
||
raise RuntimeError(
|
||
"Source Slide Master must have one Theme relationship: "
|
||
f"{source_master_part}"
|
||
)
|
||
source_theme_part = _resolve_package_target(
|
||
source_master_part,
|
||
theme_targets[0],
|
||
)
|
||
source_theme_path = extract_dir / source_theme_part
|
||
if not source_theme_path.exists():
|
||
raise RuntimeError(
|
||
f"Slide Master Theme part is missing: {source_theme_part}"
|
||
)
|
||
|
||
theme_num = _next_theme_part_number(extract_dir)
|
||
theme_part = f"ppt/theme/theme{theme_num}.xml"
|
||
theme_path = extract_dir / theme_part
|
||
shutil.copyfile(source_theme_path, theme_path)
|
||
theme_tree = ET.parse(theme_path)
|
||
theme_tree.getroot().set("name", f"{master_name} Theme")
|
||
_write_xml_tree(theme_path, theme_tree)
|
||
|
||
source_theme_rels = _relationships_path_for_part(
|
||
extract_dir,
|
||
source_theme_part,
|
||
)
|
||
if source_theme_rels.exists():
|
||
theme_rels = _relationships_path_for_part(extract_dir, theme_part)
|
||
theme_rels.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copyfile(source_theme_rels, theme_rels)
|
||
|
||
master_rels = _relationships_path_for_part(extract_dir, master_part)
|
||
theme_relationships = [
|
||
(rel_id, attrs)
|
||
for rel_id, attrs in _read_relationships(master_rels).items()
|
||
if attrs.get("Type") == THEME_REL_TYPE
|
||
]
|
||
if len(theme_relationships) != 1:
|
||
raise RuntimeError(
|
||
f"Cloned Slide Master must have one Theme relationship: {master_part}"
|
||
)
|
||
theme_rel_id, _attrs = theme_relationships[0]
|
||
theme_target = posixpath.relpath(theme_part, posixpath.dirname(master_part))
|
||
rels_content = master_rels.read_text(encoding="utf-8")
|
||
theme_rel_pattern = re.compile(
|
||
rf'(<Relationship\b[^>]*\bId="{re.escape(theme_rel_id)}"'
|
||
rf'[^>]*\bTarget=")[^"]*(")'
|
||
)
|
||
rels_content, replaced = theme_rel_pattern.subn(
|
||
rf"\g<1>{theme_target}\g<2>",
|
||
rels_content,
|
||
count=1,
|
||
)
|
||
if replaced != 1:
|
||
raise RuntimeError(
|
||
f"Could not retarget cloned Slide Master Theme: {master_part}"
|
||
)
|
||
master_rels.write_text(rels_content, encoding="utf-8")
|
||
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
content_types_path.write_text(
|
||
_add_content_type_override(
|
||
content_types_path.read_text(encoding="utf-8"),
|
||
theme_part,
|
||
THEME_CONTENT_TYPE,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return theme_part
|
||
|
||
|
||
def _clone_structured_master(
|
||
extract_dir: Path,
|
||
source_master_part: str,
|
||
master_name: str,
|
||
) -> str:
|
||
"""Clone a clean Master part and register it with the Presentation."""
|
||
master_num = _next_master_part_number(extract_dir)
|
||
master_part = f"ppt/slideMasters/slideMaster{master_num}.xml"
|
||
master_path = extract_dir / master_part
|
||
source_master_path = extract_dir / source_master_part
|
||
shutil.copyfile(source_master_path, master_path)
|
||
|
||
tree = ET.parse(master_path)
|
||
root = tree.getroot()
|
||
_reseed_p14_creation_id(root)
|
||
c_sld = root.find(f"{{{PML_NS}}}cSld")
|
||
if c_sld is None:
|
||
raise RuntimeError(f"Slide master has no p:cSld: {source_master_part}")
|
||
c_sld.set("name", master_name)
|
||
layout_list = root.find(f"{{{PML_NS}}}sldLayoutIdLst")
|
||
if layout_list is None:
|
||
raise RuntimeError(
|
||
f"Slide master has no p:sldLayoutIdLst: {source_master_part}"
|
||
)
|
||
for entry in list(layout_list):
|
||
layout_list.remove(entry)
|
||
_write_xml_tree(master_path, tree)
|
||
|
||
source_rels = _relationships_path_for_part(extract_dir, source_master_part)
|
||
master_rels = _relationships_path_for_part(extract_dir, master_part)
|
||
master_rels.parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copyfile(source_rels, master_rels)
|
||
for rel_id, attrs in tuple(_read_relationships(master_rels).items()):
|
||
if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE:
|
||
_remove_relationship(master_rels, rel_id)
|
||
_clone_master_theme(
|
||
extract_dir,
|
||
source_master_part,
|
||
master_part,
|
||
master_name,
|
||
)
|
||
|
||
presentation_rels = extract_dir / "ppt" / "_rels" / "presentation.xml.rels"
|
||
relationship_target = posixpath.relpath(master_part, "ppt")
|
||
relationship_id = _append_relationship(
|
||
presentation_rels,
|
||
SLIDE_MASTER_REL_TYPE,
|
||
relationship_target,
|
||
)
|
||
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
||
presentation_xml = presentation_path.read_text(encoding="utf-8")
|
||
master_ids = [
|
||
int(value)
|
||
for value in re.findall(r'<p:sldMasterId\b[^>]*\bid="(\d+)"', presentation_xml)
|
||
]
|
||
master_id = max(master_ids, default=(1 << 31) - 1) + 1
|
||
if master_id > OOXML_UINT32_MAX:
|
||
raise TemplateStructureError("Presentation Master id exceeds OOXML UInt32")
|
||
entry = f'<p:sldMasterId id="{master_id}" r:id="{relationship_id}"/>'
|
||
if "</p:sldMasterIdLst>" not in presentation_xml:
|
||
raise RuntimeError("presentation.xml has no p:sldMasterIdLst")
|
||
presentation_path.write_text(
|
||
presentation_xml.replace(
|
||
"</p:sldMasterIdLst>",
|
||
f"{entry}</p:sldMasterIdLst>",
|
||
1,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
content_types_path.write_text(
|
||
_add_content_type_override(
|
||
content_types_path.read_text(encoding="utf-8"),
|
||
master_part,
|
||
"application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml",
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
return master_part
|
||
|
||
|
||
def _assign_structured_masters(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
specs: list[TemplateSlideSpec],
|
||
) -> dict[str, str]:
|
||
"""Create one registered package Master for each explicit SVG Master key."""
|
||
if not specs:
|
||
raise TemplateStructureError("Structured export requires at least one slide")
|
||
source_master = structure.slide_master_part(specs[0].slide_num)
|
||
masters: dict[str, str] = {}
|
||
for spec in specs:
|
||
existing_part = masters.get(spec.master_key)
|
||
if existing_part is None:
|
||
if not masters:
|
||
existing_part = source_master
|
||
_set_master_picker_name(
|
||
extract_dir / existing_part,
|
||
spec.master_name,
|
||
)
|
||
else:
|
||
existing_part = _clone_structured_master(
|
||
extract_dir,
|
||
source_master,
|
||
spec.master_name,
|
||
)
|
||
masters[spec.master_key] = existing_part
|
||
structure.slide_master_parts[spec.slide_num] = existing_part
|
||
return masters
|
||
|
||
|
||
def _clear_master_placeholder_shapes(master_path: Path) -> None:
|
||
"""Remove base-package placeholders before installing structured content."""
|
||
tree = ET.parse(master_path)
|
||
root = tree.getroot()
|
||
sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
raise RuntimeError(f"Slide master has no p:spTree: {master_path}")
|
||
for child in list(sp_tree):
|
||
if child.find(f".//{{{PML_NS}}}ph") is not None:
|
||
sp_tree.remove(child)
|
||
_write_xml_tree(master_path, tree)
|
||
|
||
|
||
def _set_slide_layout_target(rels_path: Path, target: str) -> None:
|
||
"""Point a slide's layout relationship at a different layout part."""
|
||
content = rels_path.read_text(encoding="utf-8")
|
||
rel_id = None
|
||
for existing_id, attrs in _read_relationships(rels_path).items():
|
||
if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE:
|
||
rel_id = existing_id
|
||
break
|
||
if rel_id is None:
|
||
raise RuntimeError(f"No slide layout relationship in {rels_path}")
|
||
pattern = re.compile(
|
||
rf'(<Relationship\b[^>]*\bId="{re.escape(rel_id)}"[^>]*\bTarget=")[^"]*(")'
|
||
)
|
||
new_content, replaced = pattern.subn(rf"\g<1>{target}\g<2>", content, count=1)
|
||
if not replaced:
|
||
raise RuntimeError(f"Could not retarget layout relationship in {rels_path}")
|
||
rels_path.write_text(new_content, encoding="utf-8")
|
||
|
||
|
||
_BASELINE_LAYOUT_ROLE_TOKENS = (
|
||
("Cover", frozenset({"cover", "frontcover"}), ("封面",)),
|
||
(
|
||
"Agenda",
|
||
frozenset({"agenda", "contents", "outline", "toc"}),
|
||
("目录", "议程"),
|
||
),
|
||
(
|
||
"Section",
|
||
frozenset({"chapter", "divider", "section", "transition"}),
|
||
("章节", "过渡页"),
|
||
),
|
||
(
|
||
"Closing",
|
||
frozenset({"closing", "end", "ending", "qa", "thankyou", "thanks"}),
|
||
("封底", "结束", "结尾", "结语", "致谢", "谢谢"),
|
||
),
|
||
)
|
||
|
||
|
||
def _baseline_layout_role(svg_path: Path) -> str:
|
||
"""Use explicit page semantics, then fall back to conservative filename roles."""
|
||
semantic_role = page_layout_name_from_svg(svg_path)
|
||
if semantic_role:
|
||
return semantic_role
|
||
stem = svg_path.stem.casefold()
|
||
tokens = {
|
||
token
|
||
for token in re.split(r"[^0-9a-z]+", stem)
|
||
if token and not token.isdigit()
|
||
}
|
||
for role, english_tokens, cjk_tokens in _BASELINE_LAYOUT_ROLE_TOKENS:
|
||
if tokens.intersection(english_tokens) or any(
|
||
token in stem for token in cjk_tokens
|
||
):
|
||
return role
|
||
return "Content"
|
||
|
||
|
||
def _layout_identity(layout_path: Path) -> tuple[str, bool]:
|
||
"""Return a layout's picker name and master-shape visibility."""
|
||
root = ET.parse(layout_path).getroot()
|
||
c_sld = root.find(f"{{{PML_NS}}}cSld")
|
||
name = c_sld.attrib.get("name", "") if c_sld is not None else ""
|
||
return name, root.attrib.get("showMasterSp", "1") != "0"
|
||
|
||
|
||
def _shared_explicit_slide_background(
|
||
extract_dir: Path,
|
||
slide_nums: list[int],
|
||
) -> str | None:
|
||
"""Return one exact background only when every family slide carries it."""
|
||
backgrounds: list[str] = []
|
||
for slide_num in slide_nums:
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
background = _extract_slide_background_xml(
|
||
slide_path.read_text(encoding="utf-8")
|
||
)
|
||
if background is None:
|
||
return None
|
||
backgrounds.append(background)
|
||
if not backgrounds or len(set(backgrounds)) != 1:
|
||
return None
|
||
return backgrounds[0]
|
||
|
||
|
||
def _extract_baseline_layout_families(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
svg_files: list[Path],
|
||
*,
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Build conservative post-generation layout families for a free SVG deck."""
|
||
families: dict[tuple[str, str, bool], list[int]] = {}
|
||
base_layouts: dict[tuple[str, str, bool], str] = {}
|
||
for slide_num, svg_path in enumerate(svg_files, 1):
|
||
rels_path = (
|
||
extract_dir
|
||
/ "ppt"
|
||
/ "slides"
|
||
/ "_rels"
|
||
/ f"slide{slide_num}.xml.rels"
|
||
)
|
||
layout_target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
|
||
if not layout_target:
|
||
raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
|
||
layout_part = _resolve_package_target(
|
||
f"ppt/slides/slide{slide_num}.xml", layout_target
|
||
)
|
||
layout_name, show_master_shapes = _layout_identity(extract_dir / layout_part)
|
||
role = _baseline_layout_role(svg_path)
|
||
if role == "Content" and layout_name == COVER_LAYOUT_NAME:
|
||
role = COVER_LAYOUT_NAME
|
||
key = (
|
||
structure.slide_master_part(slide_num),
|
||
role,
|
||
show_master_shapes,
|
||
)
|
||
families.setdefault(key, []).append(slide_num)
|
||
base_layouts.setdefault(key, layout_part)
|
||
|
||
created = 0
|
||
lifted_backgrounds = 0
|
||
role_counts: dict[tuple[str, str], int] = {}
|
||
for key, slide_nums in families.items():
|
||
master_part, role, show_master_shapes = key
|
||
role_key = (master_part, role)
|
||
role_counts[role_key] = role_counts.get(role_key, 0) + 1
|
||
variant = role_counts[role_key]
|
||
layout_name = role if variant == 1 else f"{role} {variant}"
|
||
layout_target, layout_part = _create_custom_layout(
|
||
extract_dir,
|
||
master_part,
|
||
base_layouts[key],
|
||
layout_name,
|
||
show_master_shapes=show_master_shapes,
|
||
)
|
||
|
||
background_xml = _shared_explicit_slide_background(
|
||
extract_dir, slide_nums
|
||
)
|
||
if background_xml is not None:
|
||
layout_path = extract_dir / layout_part
|
||
layout_xml = layout_path.read_text(encoding="utf-8")
|
||
updated_layout_xml = _put_background_on_part(layout_xml, background_xml)
|
||
if updated_layout_xml is not None:
|
||
layout_path.write_text(updated_layout_xml, encoding="utf-8")
|
||
for slide_num in slide_nums:
|
||
slide_path = (
|
||
extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
)
|
||
slide_path.write_text(
|
||
_remove_slide_background_xml(
|
||
slide_path.read_text(encoding="utf-8")
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
lifted_backgrounds += len(slide_nums)
|
||
|
||
for slide_num in slide_nums:
|
||
rels_path = (
|
||
extract_dir
|
||
/ "ppt"
|
||
/ "slides"
|
||
/ "_rels"
|
||
/ f"slide{slide_num}.xml.rels"
|
||
)
|
||
_set_slide_layout_target(rels_path, layout_target)
|
||
created += 1
|
||
|
||
if verbose and created:
|
||
print(
|
||
" Baseline layout families: "
|
||
f"created {created} reusable layout(s), "
|
||
f"lifted {lifted_backgrounds} slide background(s)"
|
||
)
|
||
return created
|
||
|
||
|
||
def _promote_common_chrome_shapes_to_layouts(
|
||
extract_dir: Path,
|
||
slide_count: int,
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Promote exact leading chrome shared by every slide in one layout."""
|
||
if not conversion_traces:
|
||
return 0
|
||
trace_by_slide = {
|
||
int(trace.get("slide_num", 0)): trace
|
||
for trace in conversion_traces
|
||
if trace.get("slide_num") is not None
|
||
}
|
||
if len(trace_by_slide) < slide_count:
|
||
return 0
|
||
|
||
slides_by_layout: dict[str, list[int]] = {}
|
||
for slide_num in range(1, slide_count + 1):
|
||
rels_path = (
|
||
extract_dir
|
||
/ "ppt"
|
||
/ "slides"
|
||
/ "_rels"
|
||
/ f"slide{slide_num}.xml.rels"
|
||
)
|
||
layout_target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
|
||
if not layout_target:
|
||
raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
|
||
layout_part = _resolve_package_target(
|
||
f"ppt/slides/slide{slide_num}.xml",
|
||
layout_target,
|
||
)
|
||
slides_by_layout.setdefault(layout_part, []).append(slide_num)
|
||
|
||
promoted = 0
|
||
promoted_roles = 0
|
||
for layout_part, slide_nums in slides_by_layout.items():
|
||
if len(slide_nums) < 2:
|
||
continue
|
||
slide_state: dict[int, dict[str, Any]] = {}
|
||
for slide_num in slide_nums:
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
rels_path = (
|
||
extract_dir
|
||
/ "ppt"
|
||
/ "slides"
|
||
/ "_rels"
|
||
/ f"slide{slide_num}.xml.rels"
|
||
)
|
||
tree = ET.parse(slide_path)
|
||
root = tree.getroot()
|
||
slide_state[slide_num] = {
|
||
"path": slide_path,
|
||
"rels": _read_relationships(rels_path),
|
||
"root": root,
|
||
"shapes": _top_level_shapes_by_id(root),
|
||
"timing_shape_ids": _timing_shape_ids(root),
|
||
"tokens": _trace_chrome_shape_ids(trace_by_slide.get(slide_num)),
|
||
"tree": tree,
|
||
}
|
||
|
||
common_tokens = set.intersection(*(
|
||
set(state["tokens"])
|
||
for state in slide_state.values()
|
||
))
|
||
common_tokens.difference_update(_PAGE_NUMBER_TOKENS)
|
||
candidates: dict[str, dict[int, str]] = {}
|
||
for token in sorted(common_tokens):
|
||
shape_ids_by_slide: dict[int, str] = {}
|
||
canonical_shapes: set[bytes] = set()
|
||
for slide_num in slide_nums:
|
||
state = slide_state[slide_num]
|
||
shape_ids = state["tokens"].get(token, [])
|
||
if len(shape_ids) != 1:
|
||
break
|
||
shape_id = shape_ids[0]
|
||
shape = state["shapes"].get(shape_id)
|
||
if shape is None or shape_id in state["timing_shape_ids"]:
|
||
break
|
||
if not _shape_relationships_supported(shape, state["rels"]):
|
||
break
|
||
shape_ids_by_slide[slide_num] = shape_id
|
||
canonical_shapes.add(_canonical_shape_xml(shape, state["rels"]))
|
||
if len(shape_ids_by_slide) == len(slide_nums) and len(canonical_shapes) == 1:
|
||
candidates[token] = shape_ids_by_slide
|
||
|
||
if not candidates:
|
||
continue
|
||
|
||
# Layout shapes render behind slide-local shapes. Keep visual z-order by
|
||
# accepting only the identical leading prefix shared by every family page.
|
||
token_by_shape_id = {
|
||
slide_num: {
|
||
shape_ids[slide_num]: token
|
||
for token, shape_ids in candidates.items()
|
||
}
|
||
for slide_num in slide_nums
|
||
}
|
||
leading_orders: list[list[str]] = []
|
||
for slide_num in slide_nums:
|
||
order: list[str] = []
|
||
for shape_id in slide_state[slide_num]["shapes"]:
|
||
token = token_by_shape_id[slide_num].get(shape_id)
|
||
if token is None:
|
||
break
|
||
order.append(token)
|
||
leading_orders.append(order)
|
||
safe_tokens = list(leading_orders[0])
|
||
for order in leading_orders[1:]:
|
||
common_length = 0
|
||
for expected, actual in zip(safe_tokens, order):
|
||
if expected != actual:
|
||
break
|
||
common_length += 1
|
||
safe_tokens = safe_tokens[:common_length]
|
||
if not safe_tokens:
|
||
break
|
||
if not safe_tokens:
|
||
continue
|
||
|
||
layout_path = extract_dir / layout_part
|
||
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
|
||
for token in safe_tokens:
|
||
shape_ids_by_slide = candidates[token]
|
||
first_slide = slide_nums[0]
|
||
first_state = slide_state[first_slide]
|
||
shape = first_state["shapes"][shape_ids_by_slide[first_slide]]
|
||
layout_shape = _copy_shape_relationships_to_part(
|
||
shape,
|
||
first_state["rels"],
|
||
layout_rels_path,
|
||
)
|
||
_append_shape_to_part(layout_path, layout_shape)
|
||
promoted_roles += 1
|
||
for slide_num, shape_id in shape_ids_by_slide.items():
|
||
state = slide_state[slide_num]
|
||
shape_to_remove = state["shapes"].get(shape_id)
|
||
sp_tree = state["root"].find(
|
||
f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree"
|
||
)
|
||
if sp_tree is not None and shape_to_remove is not None:
|
||
sp_tree.remove(shape_to_remove)
|
||
promoted += 1
|
||
|
||
for slide_num in slide_nums:
|
||
state = slide_state[slide_num]
|
||
_write_xml_tree(state["path"], state["tree"])
|
||
|
||
if verbose and promoted:
|
||
print(
|
||
" Baseline layout chrome: "
|
||
f"promoted {promoted} slide shape(s) across "
|
||
f"{promoted_roles} shared object(s)"
|
||
)
|
||
return promoted
|
||
|
||
|
||
_TEMPLATE_PLACEHOLDER_PROMPTS = {
|
||
"title": "Click to add title",
|
||
"subtitle": "Click to add subtitle",
|
||
"body": "Click to add text",
|
||
"picture": "Click to add picture",
|
||
"chart": "Click to add chart",
|
||
"table": "Click to add table",
|
||
"object": "Click to add content",
|
||
"media": "Click to add media",
|
||
"date": "Date",
|
||
"footer": "Footer",
|
||
}
|
||
|
||
_PARAGRAPH_BULLET_CHOICE_TAGS = {
|
||
f"{{{DML_NS}}}buNone",
|
||
f"{{{DML_NS}}}buAutoNum",
|
||
f"{{{DML_NS}}}buChar",
|
||
f"{{{DML_NS}}}buBlip",
|
||
}
|
||
_PARAGRAPH_PROPERTIES_TRAILING_TAGS = {
|
||
f"{{{DML_NS}}}tabLst",
|
||
f"{{{DML_NS}}}defRPr",
|
||
f"{{{DML_NS}}}extLst",
|
||
}
|
||
|
||
|
||
def _template_runtime_slides(
|
||
extract_dir: Path,
|
||
specs: list[TemplateSlideSpec],
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
) -> list[_TemplateRuntimeSlide]:
|
||
"""Load slide XML state and join it with SVG-to-shape trace ids."""
|
||
if not conversion_traces:
|
||
raise TemplateStructureError(
|
||
"Explicit Layout export requires native conversion traces for every slide"
|
||
)
|
||
trace_by_slide = {
|
||
int(trace.get("slide_num", 0)): trace
|
||
for trace in conversion_traces
|
||
if trace.get("slide_num") is not None
|
||
}
|
||
states: list[_TemplateRuntimeSlide] = []
|
||
for spec in specs:
|
||
trace = trace_by_slide.get(spec.slide_num)
|
||
if trace is None:
|
||
raise TemplateStructureError(
|
||
f"{spec.svg_path.name}: missing native conversion trace"
|
||
)
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{spec.slide_num}.xml"
|
||
rels_path = (
|
||
extract_dir
|
||
/ "ppt"
|
||
/ "slides"
|
||
/ "_rels"
|
||
/ f"slide{spec.slide_num}.xml.rels"
|
||
)
|
||
tree = ET.parse(slide_path)
|
||
root = tree.getroot()
|
||
states.append(_TemplateRuntimeSlide(
|
||
spec=spec,
|
||
slide_path=slide_path,
|
||
rels_path=rels_path,
|
||
tree=tree,
|
||
root=root,
|
||
rels=_read_relationships(rels_path),
|
||
shapes=_top_level_shapes_by_id(root),
|
||
shape_ids_by_svg_id=_trace_native_shape_ids(trace),
|
||
))
|
||
return states
|
||
|
||
|
||
def _template_shape_for_item(
|
||
state: _TemplateRuntimeSlide,
|
||
item: TemplateElementSpec,
|
||
) -> ET.Element | None:
|
||
"""Resolve one metadata item to its generated top-level DrawingML shape."""
|
||
shape_ids = [
|
||
shape_id
|
||
for shape_id in state.shape_ids_by_svg_id.get(item.element_id, [])
|
||
if shape_id in state.shapes
|
||
]
|
||
if len(shape_ids) == 1:
|
||
return state.shapes[shape_ids[0]]
|
||
if not shape_ids and item.layer and item.order == 0 and item.tag in {"rect", "g"}:
|
||
if _extract_slide_background_xml(
|
||
state.slide_path.read_text(encoding="utf-8")
|
||
):
|
||
return None
|
||
if not shape_ids:
|
||
text_hint = (
|
||
"; multiline text placeholders require a single-frame text mode "
|
||
"and cannot use --no-merge"
|
||
if item.placeholder and item.placeholder_carrier_tag == "text"
|
||
else ""
|
||
)
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: metadata element {item.element_id!r} "
|
||
f"did not produce one top-level native shape{text_hint}"
|
||
)
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: metadata element {item.element_id!r} "
|
||
f"resolved to {len(shape_ids)} top-level shapes; use one direct SVG element"
|
||
)
|
||
|
||
|
||
def _shape_transform(shape: ET.Element) -> ET.Element | None:
|
||
"""Return the direct DrawingML transform for one top-level shape."""
|
||
paths = {
|
||
f"{{{PML_NS}}}sp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
|
||
f"{{{PML_NS}}}pic": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
|
||
f"{{{PML_NS}}}cxnSp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
|
||
f"{{{PML_NS}}}graphicFrame": f"{{{PML_NS}}}xfrm",
|
||
f"{{{PML_NS}}}grpSp": f"{{{PML_NS}}}grpSpPr/{{{DML_NS}}}xfrm",
|
||
}
|
||
path = paths.get(shape.tag)
|
||
return shape.find(path) if path is not None else None
|
||
|
||
|
||
def _int_attr(elem: ET.Element, name: str, context: str) -> int:
|
||
try:
|
||
return int(elem.attrib[name])
|
||
except (KeyError, ValueError) as exc:
|
||
raise TemplateStructureError(f"{context} has invalid {name!r}") from exc
|
||
|
||
|
||
def _flatten_group_transform(
|
||
group: ET.Element,
|
||
carrier: ET.Element,
|
||
*,
|
||
context: str,
|
||
) -> None:
|
||
"""Map a single group's child transform into slide coordinates."""
|
||
group_xfrm = _shape_transform(group)
|
||
carrier_xfrm = _shape_transform(carrier)
|
||
if group_xfrm is None or carrier_xfrm is None:
|
||
raise TemplateStructureError(
|
||
f"{context} cannot be unwrapped because its DrawingML transform is missing"
|
||
)
|
||
if any(group_xfrm.get(name) is not None for name in ("rot", "flipH", "flipV")):
|
||
raise TemplateStructureError(
|
||
f"{context} wrapper carries an unsupported group rotation or flip"
|
||
)
|
||
group_off = group_xfrm.find(f"{{{DML_NS}}}off")
|
||
group_ext = group_xfrm.find(f"{{{DML_NS}}}ext")
|
||
child_off = group_xfrm.find(f"{{{DML_NS}}}chOff")
|
||
child_ext = group_xfrm.find(f"{{{DML_NS}}}chExt")
|
||
carrier_off = carrier_xfrm.find(f"{{{DML_NS}}}off")
|
||
carrier_ext = carrier_xfrm.find(f"{{{DML_NS}}}ext")
|
||
if any(value is None for value in (
|
||
group_off,
|
||
group_ext,
|
||
child_off,
|
||
child_ext,
|
||
carrier_off,
|
||
carrier_ext,
|
||
)):
|
||
raise TemplateStructureError(
|
||
f"{context} cannot be unwrapped because its group transform is incomplete"
|
||
)
|
||
child_width = _int_attr(child_ext, "cx", context)
|
||
child_height = _int_attr(child_ext, "cy", context)
|
||
if child_width <= 0 or child_height <= 0:
|
||
raise TemplateStructureError(f"{context} group child extent must be positive")
|
||
scale_x = _int_attr(group_ext, "cx", context) / child_width
|
||
scale_y = _int_attr(group_ext, "cy", context) / child_height
|
||
mapped_x = _int_attr(group_off, "x", context) + round(
|
||
(_int_attr(carrier_off, "x", context) - _int_attr(child_off, "x", context))
|
||
* scale_x
|
||
)
|
||
mapped_y = _int_attr(group_off, "y", context) + round(
|
||
(_int_attr(carrier_off, "y", context) - _int_attr(child_off, "y", context))
|
||
* scale_y
|
||
)
|
||
carrier_off.set("x", str(mapped_x))
|
||
carrier_off.set("y", str(mapped_y))
|
||
carrier_ext.set(
|
||
"cx",
|
||
str(round(_int_attr(carrier_ext, "cx", context) * scale_x)),
|
||
)
|
||
carrier_ext.set(
|
||
"cy",
|
||
str(round(_int_attr(carrier_ext, "cy", context) * scale_y)),
|
||
)
|
||
|
||
|
||
def _unwrap_placeholder_carrier(
|
||
state: _TemplateRuntimeSlide,
|
||
item: TemplateElementSpec,
|
||
) -> ET.Element:
|
||
"""Remove one SVG-only slot wrapper and return its top-level carrier."""
|
||
wrapper = _template_shape_for_item(state, item)
|
||
if wrapper is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: placeholder {item.element_id!r} cannot "
|
||
"be a slide background"
|
||
)
|
||
if item.tag != "g" or wrapper.tag != f"{{{PML_NS}}}grpSp":
|
||
return wrapper
|
||
carriers = [
|
||
child for child in wrapper if child.tag in _TOP_LEVEL_SHAPE_TAGS
|
||
]
|
||
if len(carriers) != 1:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: placeholder group {item.element_id!r} "
|
||
f"converted to {len(carriers)} native children; expected one carrier"
|
||
)
|
||
carrier = carriers[0]
|
||
_flatten_group_transform(
|
||
wrapper,
|
||
carrier,
|
||
context=f"{state.spec.svg_path.name} placeholder {item.element_id!r}",
|
||
)
|
||
sp_tree = _slide_sp_tree(state)
|
||
try:
|
||
wrapper_index = list(sp_tree).index(wrapper)
|
||
except ValueError as exc:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: placeholder wrapper "
|
||
f"{item.element_id!r} is not top-level"
|
||
) from exc
|
||
wrapper.remove(carrier)
|
||
sp_tree.remove(wrapper)
|
||
sp_tree.insert(wrapper_index, carrier)
|
||
wrapper_id = _shape_id(wrapper)
|
||
carrier_id = _shape_id(carrier)
|
||
if wrapper_id:
|
||
state.shapes.pop(wrapper_id, None)
|
||
if carrier_id:
|
||
state.shapes[carrier_id] = carrier
|
||
return carrier
|
||
|
||
|
||
def _slide_sp_tree(state: _TemplateRuntimeSlide) -> ET.Element:
|
||
sp_tree = state.root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is None:
|
||
raise RuntimeError(f"Slide has no p:spTree: {state.slide_path}")
|
||
return sp_tree
|
||
|
||
|
||
def _append_shape_to_runtime_slide(
|
||
state: _TemplateRuntimeSlide,
|
||
elem: ET.Element,
|
||
) -> None:
|
||
"""Append a generated helper shape to one Slide with fresh object ids."""
|
||
existing_ids = [
|
||
int(cnv.attrib["id"])
|
||
for cnv in state.root.iter(f"{{{PML_NS}}}cNvPr")
|
||
if cnv.attrib.get("id", "").isdigit()
|
||
]
|
||
clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
|
||
_renumber_shape_ids(clone, max(existing_ids, default=1) + 1)
|
||
_slide_sp_tree(state).append(clone)
|
||
|
||
|
||
def _presentation_slide_size_emu(extract_dir: Path) -> tuple[int, int]:
|
||
"""Return the package slide size in EMU."""
|
||
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
||
root = ET.parse(presentation_path).getroot()
|
||
slide_size = root.find(f"{{{PML_NS}}}sldSz")
|
||
if slide_size is None:
|
||
raise TemplateStructureError("presentation.xml has no p:sldSz")
|
||
try:
|
||
width = int(slide_size.attrib["cx"])
|
||
height = int(slide_size.attrib["cy"])
|
||
except (KeyError, ValueError) as exc:
|
||
raise TemplateStructureError("presentation.xml has an invalid p:sldSz") from exc
|
||
if width <= 0 or height <= 0:
|
||
raise TemplateStructureError("presentation.xml p:sldSz must be positive")
|
||
return width, height
|
||
|
||
|
||
def _solid_background_xml_from_shape(
|
||
shape: ET.Element,
|
||
slide_size_emu: tuple[int, int],
|
||
) -> str | None:
|
||
"""Convert one exact full-slide solid rectangle shape into p:bg XML."""
|
||
if shape.tag != f"{{{PML_NS}}}sp":
|
||
return None
|
||
if shape.find(f"{{{PML_NS}}}txBody") is not None:
|
||
return None
|
||
sp_pr = shape.find(f"{{{PML_NS}}}spPr")
|
||
if sp_pr is None:
|
||
return None
|
||
xfrm = sp_pr.find(f"{{{DML_NS}}}xfrm")
|
||
if xfrm is None or xfrm.attrib:
|
||
return None
|
||
off = xfrm.find(f"{{{DML_NS}}}off")
|
||
ext = xfrm.find(f"{{{DML_NS}}}ext")
|
||
if off is None or ext is None:
|
||
return None
|
||
try:
|
||
bounds = (
|
||
int(off.attrib["x"]),
|
||
int(off.attrib["y"]),
|
||
int(ext.attrib["cx"]),
|
||
int(ext.attrib["cy"]),
|
||
)
|
||
except (KeyError, ValueError):
|
||
return None
|
||
if bounds != (0, 0, *slide_size_emu):
|
||
return None
|
||
|
||
geometry = sp_pr.find(f"{{{DML_NS}}}prstGeom")
|
||
if geometry is None or geometry.attrib.get("prst") != "rect":
|
||
return None
|
||
solid_fill = sp_pr.find(f"{{{DML_NS}}}solidFill")
|
||
if solid_fill is None:
|
||
return None
|
||
competing_fills = {
|
||
f"{{{DML_NS}}}noFill",
|
||
f"{{{DML_NS}}}gradFill",
|
||
f"{{{DML_NS}}}blipFill",
|
||
f"{{{DML_NS}}}pattFill",
|
||
f"{{{DML_NS}}}grpFill",
|
||
}
|
||
if any(child.tag in competing_fills for child in sp_pr):
|
||
return None
|
||
line = sp_pr.find(f"{{{DML_NS}}}ln")
|
||
if line is not None and line.find(f"{{{DML_NS}}}noFill") is None:
|
||
return None
|
||
for effect_tag in (f"{{{DML_NS}}}effectLst", f"{{{DML_NS}}}effectDag"):
|
||
effect = sp_pr.find(effect_tag)
|
||
if effect is not None and (effect.attrib or list(effect)):
|
||
return None
|
||
|
||
background = ET.Element(f"{{{PML_NS}}}bg")
|
||
background_props = ET.SubElement(background, f"{{{PML_NS}}}bgPr")
|
||
background_props.append(
|
||
ET.fromstring(ET.tostring(solid_fill, encoding="utf-8"))
|
||
)
|
||
ET.SubElement(background_props, f"{{{DML_NS}}}effectLst")
|
||
return ET.tostring(background, encoding="unicode")
|
||
|
||
|
||
def _remove_template_shape(
|
||
state: _TemplateRuntimeSlide,
|
||
shape: ET.Element,
|
||
) -> None:
|
||
sp_tree = _slide_sp_tree(state)
|
||
if shape not in list(sp_tree):
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: structure shape is not slide-local"
|
||
)
|
||
sp_tree.remove(shape)
|
||
|
||
|
||
def _move_template_background(
|
||
states: list[_TemplateRuntimeSlide],
|
||
target_path: Path,
|
||
) -> str:
|
||
backgrounds = [
|
||
_extract_slide_background_xml(state.slide_path.read_text(encoding="utf-8"))
|
||
for state in states
|
||
]
|
||
if not backgrounds or any(background is None for background in backgrounds):
|
||
raise TemplateStructureError(
|
||
"Template background metadata must resolve to an explicit background "
|
||
"on every affected slide"
|
||
)
|
||
canonical_backgrounds = set()
|
||
for background in backgrounds:
|
||
if background is None:
|
||
continue
|
||
wrapper = ET.fromstring(
|
||
f'<root xmlns:p="{PML_NS}" xmlns:a="{DML_NS}">{background}</root>'
|
||
)
|
||
canonical_backgrounds.add(
|
||
ET.tostring(list(wrapper)[0], encoding="utf-8")
|
||
)
|
||
if len(canonical_backgrounds) != 1:
|
||
slide_names = ", ".join(state.spec.svg_path.name for state in states)
|
||
raise TemplateStructureError(
|
||
f"Explicit template background differs across slides: {slide_names}"
|
||
)
|
||
background_xml = backgrounds[0]
|
||
if background_xml is None:
|
||
raise TemplateStructureError("Template background is unexpectedly empty")
|
||
target_xml = target_path.read_text(encoding="utf-8")
|
||
updated = _put_background_on_part(target_xml, background_xml)
|
||
if updated is None:
|
||
raise TemplateStructureError(
|
||
f"Cannot install explicit background on {target_path.name}"
|
||
)
|
||
target_path.write_text(updated, encoding="utf-8")
|
||
for state in states:
|
||
c_sld = state.root.find(f"{{{PML_NS}}}cSld")
|
||
background = (
|
||
c_sld.find(f"{{{PML_NS}}}bg") if c_sld is not None else None
|
||
)
|
||
if c_sld is None or background is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: explicit background disappeared "
|
||
"during explicit Layout structure assembly"
|
||
)
|
||
c_sld.remove(background)
|
||
return background_xml
|
||
|
||
|
||
def _move_template_solid_background_shapes(
|
||
states: list[_TemplateRuntimeSlide],
|
||
shapes: list[ET.Element],
|
||
target_path: Path,
|
||
slide_size_emu: tuple[int, int],
|
||
) -> str | None:
|
||
"""Move repeated full-slide solid rects into a master/layout p:bg."""
|
||
backgrounds = [
|
||
_solid_background_xml_from_shape(shape, slide_size_emu)
|
||
for shape in shapes
|
||
]
|
||
if not any(backgrounds):
|
||
return None
|
||
if any(background is None for background in backgrounds):
|
||
raise TemplateStructureError(
|
||
"A template background resolves to a full-slide solid rect on only "
|
||
"some slides sharing the structure"
|
||
)
|
||
canonical = {background for background in backgrounds if background is not None}
|
||
if len(canonical) != 1:
|
||
slide_names = ", ".join(state.spec.svg_path.name for state in states)
|
||
raise TemplateStructureError(
|
||
f"Explicit template solid background differs across slides: {slide_names}"
|
||
)
|
||
background_xml = backgrounds[0]
|
||
if background_xml is None:
|
||
return None
|
||
target_xml = target_path.read_text(encoding="utf-8")
|
||
updated = _put_background_on_part(target_xml, background_xml)
|
||
if updated is None:
|
||
raise TemplateStructureError(
|
||
f"Cannot install explicit solid background on {target_path.name}"
|
||
)
|
||
target_path.write_text(updated, encoding="utf-8")
|
||
for state, shape in zip(states, shapes):
|
||
_remove_template_shape(state, shape)
|
||
return background_xml
|
||
|
||
|
||
def _set_slide_tree_background(
|
||
state: _TemplateRuntimeSlide,
|
||
background_xml: str,
|
||
) -> None:
|
||
"""Replace the slide tree's p:bg with explicit background XML."""
|
||
c_sld = state.root.find(f"{{{PML_NS}}}cSld")
|
||
if c_sld is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: slide has no p:cSld"
|
||
)
|
||
for existing in list(c_sld):
|
||
if existing.tag == f"{{{PML_NS}}}bg":
|
||
c_sld.remove(existing)
|
||
background = ET.fromstring(background_xml)
|
||
sp_tree_tag = f"{{{PML_NS}}}spTree"
|
||
insert_at = next(
|
||
(index for index, child in enumerate(c_sld) if child.tag == sp_tree_tag),
|
||
0,
|
||
)
|
||
c_sld.insert(insert_at, background)
|
||
|
||
|
||
def _apply_template_slide_backgrounds(
|
||
states: list[_TemplateRuntimeSlide],
|
||
slide_size_emu: tuple[int, int],
|
||
) -> dict[str, str]:
|
||
"""Compile one-page solid backgrounds into slide-level p:bg."""
|
||
applied: dict[str, str] = {}
|
||
for state in states:
|
||
items = [
|
||
item for item in state.spec.elements
|
||
if item.layer == "slide" and item.is_background
|
||
]
|
||
if not items:
|
||
continue
|
||
item = items[0]
|
||
shape = _template_shape_for_item(state, item)
|
||
if shape is None:
|
||
c_sld = state.root.find(f"{{{PML_NS}}}cSld")
|
||
background = (
|
||
c_sld.find(f"{{{PML_NS}}}bg")
|
||
if c_sld is not None
|
||
else None
|
||
)
|
||
if background is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: slide background disappeared "
|
||
"during explicit Layout structure assembly"
|
||
)
|
||
applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = ET.tostring(
|
||
background,
|
||
encoding="unicode",
|
||
)
|
||
continue
|
||
background_xml = _solid_background_xml_from_shape(shape, slide_size_emu)
|
||
if background_xml is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: {item.element_id!r} must remain an "
|
||
"exact full-slide solid rectangle"
|
||
)
|
||
_remove_template_shape(state, shape)
|
||
_set_slide_tree_background(state, background_xml)
|
||
applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = background_xml
|
||
return applied
|
||
|
||
|
||
def _move_template_static_shape(
|
||
states: list[_TemplateRuntimeSlide],
|
||
item: TemplateElementSpec,
|
||
target_path: Path,
|
||
target_rels_path: Path,
|
||
slide_size_emu: tuple[int, int],
|
||
) -> str | None:
|
||
shapes = [_template_shape_for_item(state, item) for state in states]
|
||
if any(shape is None for shape in shapes):
|
||
if not all(shape is None for shape in shapes):
|
||
raise TemplateStructureError(
|
||
f"{item.element_id}: structure item is a background on only some slides"
|
||
)
|
||
return _move_template_background(states, target_path)
|
||
|
||
resolved_shapes = [shape for shape in shapes if shape is not None]
|
||
if item.is_background:
|
||
background_xml = _move_template_solid_background_shapes(
|
||
states,
|
||
resolved_shapes,
|
||
target_path,
|
||
slide_size_emu,
|
||
)
|
||
if background_xml is None:
|
||
raise TemplateStructureError(
|
||
f"{item.element_id!r} must compile to one exact p:bg payload"
|
||
)
|
||
return background_xml
|
||
canonical = {
|
||
_canonical_shape_xml(shape, state.rels)
|
||
for state, shape in zip(states, resolved_shapes)
|
||
}
|
||
if len(canonical) != 1:
|
||
slide_names = ", ".join(state.spec.svg_path.name for state in states)
|
||
raise TemplateStructureError(
|
||
f"Explicit structure element {item.element_id!r} differs across slides: "
|
||
f"{slide_names}"
|
||
)
|
||
for state, shape in zip(states, resolved_shapes):
|
||
shape_id = _shape_id(shape)
|
||
if shape_id and shape_id in _timing_shape_ids(state.root):
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: structure element {item.element_id!r} "
|
||
"is referenced by slide timing"
|
||
)
|
||
if not _shape_relationships_supported(shape, state.rels):
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: structure element {item.element_id!r} "
|
||
"uses a non-image or external relationship"
|
||
)
|
||
|
||
prototype_state = states[0]
|
||
prototype_shape = resolved_shapes[0]
|
||
target_shape = _copy_shape_relationships_to_part(
|
||
prototype_shape,
|
||
prototype_state.rels,
|
||
target_rels_path,
|
||
)
|
||
_set_shape_name(target_shape, f"{item.element_id} {item.layer.title()}")
|
||
_append_shape_to_part(target_path, target_shape)
|
||
for state, shape in zip(states, resolved_shapes):
|
||
_remove_template_shape(state, shape)
|
||
return None
|
||
|
||
|
||
def _shape_bounds_emu(
|
||
shape: ET.Element,
|
||
override_px: tuple[float, float, float, float] | None,
|
||
) -> tuple[int, int, int, int]:
|
||
if override_px is not None:
|
||
x, y, width, height = override_px
|
||
return tuple(
|
||
round(value * EMU_PER_PX)
|
||
for value in (x, y, width, height)
|
||
)
|
||
|
||
xfrm = shape.find(f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm")
|
||
if xfrm is None:
|
||
xfrm = shape.find(f"{{{PML_NS}}}xfrm")
|
||
if xfrm is None:
|
||
raise TemplateStructureError(
|
||
"Placeholder shape has no directly readable DrawingML transform; "
|
||
"set data-pptx-bounds"
|
||
)
|
||
off = xfrm.find(f"{{{DML_NS}}}off")
|
||
ext = xfrm.find(f"{{{DML_NS}}}ext")
|
||
if off is None or ext is None:
|
||
raise TemplateStructureError("Placeholder transform has no a:off/a:ext")
|
||
try:
|
||
return (
|
||
int(off.attrib["x"]),
|
||
int(off.attrib["y"]),
|
||
int(ext.attrib["cx"]),
|
||
int(ext.attrib["cy"]),
|
||
)
|
||
except (KeyError, ValueError) as exc:
|
||
raise TemplateStructureError("Placeholder transform is invalid") from exc
|
||
|
||
|
||
def _replace_shape_xfrm(
|
||
sp_pr: ET.Element,
|
||
bounds: tuple[int, int, int, int],
|
||
) -> None:
|
||
for existing in list(sp_pr):
|
||
if existing.tag == f"{{{DML_NS}}}xfrm":
|
||
sp_pr.remove(existing)
|
||
x, y, width, height = bounds
|
||
xfrm = ET.Element(f"{{{DML_NS}}}xfrm")
|
||
ET.SubElement(xfrm, f"{{{DML_NS}}}off", {"x": str(x), "y": str(y)})
|
||
ET.SubElement(
|
||
xfrm,
|
||
f"{{{DML_NS}}}ext",
|
||
{"cx": str(width), "cy": str(height)},
|
||
)
|
||
sp_pr.insert(0, xfrm)
|
||
|
||
|
||
def _placeholder_vertical_anchor(
|
||
source_bounds: tuple[int, int, int, int],
|
||
target_bounds: tuple[int, int, int, int],
|
||
) -> str:
|
||
"""Preserve an intentionally centered carrier inside its full slot frame."""
|
||
_, source_y, _, source_height = source_bounds
|
||
_, target_y, _, target_height = target_bounds
|
||
source_center = source_y + source_height / 2
|
||
target_center = target_y + target_height / 2
|
||
return (
|
||
"ctr"
|
||
if abs(source_center - target_center) <= target_height * 0.2
|
||
else "t"
|
||
)
|
||
|
||
|
||
def _normalize_placeholder_body_properties(
|
||
body_pr: ET.Element,
|
||
source_bounds: tuple[int, int, int, int],
|
||
target_bounds: tuple[int, int, int, int],
|
||
) -> None:
|
||
"""Make a full-frame placeholder wrap text while preserving vertical intent."""
|
||
body_pr.set("wrap", "square")
|
||
body_pr.set("anchor", _placeholder_vertical_anchor(source_bounds, target_bounds))
|
||
body_pr.set("anchorCtr", "0")
|
||
autofit_tags = {
|
||
f"{{{DML_NS}}}noAutofit",
|
||
f"{{{DML_NS}}}normAutofit",
|
||
f"{{{DML_NS}}}spAutoFit",
|
||
}
|
||
for child in list(body_pr):
|
||
if child.tag in autofit_tags:
|
||
body_pr.remove(child)
|
||
body_pr.append(ET.Element(f"{{{DML_NS}}}noAutofit"))
|
||
|
||
|
||
def _apply_layout_frame_to_placeholder_carrier(
|
||
shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
) -> None:
|
||
"""Use the reusable Layout bounds on one template-review Slide carrier."""
|
||
if item.placeholder_bounds is None:
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} has no reusable Layout bounds"
|
||
)
|
||
source_bounds = _shape_bounds_emu(shape, None)
|
||
target_bounds = _shape_bounds_emu(shape, item.placeholder_bounds)
|
||
if shape.tag in {f"{{{PML_NS}}}sp", f"{{{PML_NS}}}pic"}:
|
||
sp_pr = shape.find(f"{{{PML_NS}}}spPr")
|
||
if sp_pr is None:
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} has no p:spPr"
|
||
)
|
||
_replace_shape_xfrm(sp_pr, target_bounds)
|
||
elif shape.tag == f"{{{PML_NS}}}graphicFrame":
|
||
xfrm = shape.find(f"{{{PML_NS}}}xfrm")
|
||
if xfrm is None:
|
||
xfrm = ET.Element(f"{{{PML_NS}}}xfrm")
|
||
shape.insert(1, xfrm)
|
||
for child in list(xfrm):
|
||
if child.tag in {f"{{{DML_NS}}}off", f"{{{DML_NS}}}ext"}:
|
||
xfrm.remove(child)
|
||
x, y, width, height = target_bounds
|
||
ET.SubElement(xfrm, f"{{{DML_NS}}}off", {"x": str(x), "y": str(y)})
|
||
ET.SubElement(
|
||
xfrm,
|
||
f"{{{DML_NS}}}ext",
|
||
{"cx": str(width), "cy": str(height)},
|
||
)
|
||
else:
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} cannot use Layout bounds on "
|
||
f"DrawingML element {shape.tag.rsplit('}', 1)[-1]!r}"
|
||
)
|
||
|
||
tx_body = shape.find(f"{{{PML_NS}}}txBody")
|
||
if tx_body is None:
|
||
return
|
||
body_pr = tx_body.find(f"{{{DML_NS}}}bodyPr")
|
||
if body_pr is None:
|
||
body_pr = ET.Element(f"{{{DML_NS}}}bodyPr")
|
||
tx_body.insert(0, body_pr)
|
||
_normalize_placeholder_body_properties(
|
||
body_pr,
|
||
source_bounds,
|
||
target_bounds,
|
||
)
|
||
|
||
|
||
def _layout_level_one_paragraph_properties(
|
||
list_style: ET.Element,
|
||
) -> ET.Element:
|
||
"""Return the Layout list style's level-one paragraph properties."""
|
||
level_tag = f"{{{DML_NS}}}lvl1pPr"
|
||
level_props = list_style.find(level_tag)
|
||
if level_props is None:
|
||
level_props = ET.Element(level_tag)
|
||
trailing_tags = {
|
||
f"{{{DML_NS}}}lvl{level}pPr" for level in range(2, 10)
|
||
}
|
||
trailing_tags.add(f"{{{DML_NS}}}extLst")
|
||
insert_at = next(
|
||
(
|
||
index
|
||
for index, child in enumerate(list_style)
|
||
if child.tag in trailing_tags
|
||
),
|
||
len(list_style),
|
||
)
|
||
list_style.insert(insert_at, level_props)
|
||
return level_props
|
||
|
||
|
||
def _set_layout_level_one_default_size(
|
||
list_style: ET.Element,
|
||
source_run_pr: ET.Element | None,
|
||
) -> None:
|
||
"""Persist the prototype run size as the Layout's level-one text default."""
|
||
if source_run_pr is None or source_run_pr.get("sz") is None:
|
||
return
|
||
level_props = _layout_level_one_paragraph_properties(list_style)
|
||
default_props = level_props.find(f"{{{DML_NS}}}defRPr")
|
||
if default_props is None:
|
||
default_props = ET.Element(f"{{{DML_NS}}}defRPr")
|
||
ext_tag = f"{{{DML_NS}}}extLst"
|
||
insert_at = next(
|
||
(
|
||
index
|
||
for index, child in enumerate(level_props)
|
||
if child.tag == ext_tag
|
||
),
|
||
len(level_props),
|
||
)
|
||
level_props.insert(insert_at, default_props)
|
||
default_props.set("sz", source_run_pr.get("sz", ""))
|
||
|
||
|
||
def _set_no_bullet_paragraph_properties(
|
||
paragraph_props: ET.Element,
|
||
*,
|
||
replace_existing: bool = False,
|
||
) -> None:
|
||
"""Disable inherited bullets and hanging indent for a prose paragraph."""
|
||
if replace_existing:
|
||
for child in list(paragraph_props):
|
||
if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS:
|
||
paragraph_props.remove(child)
|
||
|
||
bullet_choice = next(
|
||
(
|
||
child
|
||
for child in paragraph_props
|
||
if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS
|
||
),
|
||
None,
|
||
)
|
||
if bullet_choice is not None and bullet_choice.tag != f"{{{DML_NS}}}buNone":
|
||
return
|
||
if bullet_choice is None:
|
||
insert_at = next(
|
||
(
|
||
index
|
||
for index, child in enumerate(paragraph_props)
|
||
if child.tag in _PARAGRAPH_PROPERTIES_TRAILING_TAGS
|
||
),
|
||
len(paragraph_props),
|
||
)
|
||
paragraph_props.insert(insert_at, ET.Element(f"{{{DML_NS}}}buNone"))
|
||
|
||
paragraph_props.set("marL", "0")
|
||
paragraph_props.set("indent", "0")
|
||
|
||
|
||
def _placeholder_text_body(
|
||
source_shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
) -> ET.Element:
|
||
tx_body = ET.Element(f"{{{PML_NS}}}txBody")
|
||
source_tx_body = source_shape.find(f"{{{PML_NS}}}txBody")
|
||
source_body_pr = (
|
||
source_tx_body.find(f"{{{DML_NS}}}bodyPr")
|
||
if source_tx_body is not None
|
||
else None
|
||
)
|
||
source_lst_style = (
|
||
source_tx_body.find(f"{{{DML_NS}}}lstStyle")
|
||
if source_tx_body is not None
|
||
else None
|
||
)
|
||
source_run_pr = (
|
||
source_tx_body.find(f".//{{{DML_NS}}}rPr")
|
||
if source_tx_body is not None
|
||
else None
|
||
)
|
||
body_pr = (
|
||
ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
|
||
if source_body_pr is not None
|
||
else ET.Element(f"{{{DML_NS}}}bodyPr")
|
||
)
|
||
target_bounds = _shape_bounds_emu(source_shape, item.placeholder_bounds)
|
||
try:
|
||
source_bounds = _shape_bounds_emu(source_shape, None)
|
||
except TemplateStructureError:
|
||
# Composite proxy content may compile to p:grpSp, whose transform is
|
||
# intentionally not reused for the Layout's synthetic p:sp carrier.
|
||
# The explicit design-zone bounds remain the authoritative frame.
|
||
source_bounds = target_bounds
|
||
_normalize_placeholder_body_properties(
|
||
body_pr,
|
||
source_bounds,
|
||
target_bounds,
|
||
)
|
||
tx_body.append(body_pr)
|
||
list_style = (
|
||
ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
|
||
if source_lst_style is not None
|
||
else ET.Element(f"{{{DML_NS}}}lstStyle")
|
||
)
|
||
_set_layout_level_one_default_size(list_style, source_run_pr)
|
||
if item.placeholder in {"body", "subtitle"}:
|
||
_set_no_bullet_paragraph_properties(
|
||
_layout_level_one_paragraph_properties(list_style),
|
||
replace_existing=item.placeholder == "body",
|
||
)
|
||
tx_body.append(list_style)
|
||
|
||
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
|
||
source_paragraph_props = (
|
||
source_tx_body.find(f"{{{DML_NS}}}p/{{{DML_NS}}}pPr")
|
||
if source_tx_body is not None
|
||
else None
|
||
)
|
||
paragraph_props = (
|
||
ET.fromstring(ET.tostring(source_paragraph_props, encoding="utf-8"))
|
||
if source_paragraph_props is not None
|
||
else None
|
||
)
|
||
if item.placeholder in {"body", "subtitle"}:
|
||
if paragraph_props is None:
|
||
paragraph_props = ET.Element(f"{{{DML_NS}}}pPr")
|
||
_set_no_bullet_paragraph_properties(paragraph_props)
|
||
if paragraph_props is not None:
|
||
paragraph.append(paragraph_props)
|
||
if item.placeholder in {"slide-number", "date"}:
|
||
field_type = (
|
||
"slidenum"
|
||
if item.placeholder == "slide-number"
|
||
else "datetimeFigureOut"
|
||
)
|
||
field = ET.SubElement(
|
||
paragraph,
|
||
f"{{{DML_NS}}}fld",
|
||
{"id": f"{{{str(uuid.uuid4()).upper()}}}", "type": field_type},
|
||
)
|
||
if source_run_pr is not None:
|
||
field.append(ET.fromstring(ET.tostring(source_run_pr, encoding="utf-8")))
|
||
source_text = ""
|
||
if source_tx_body is not None:
|
||
source_text = "".join(
|
||
text.text or ""
|
||
for text in source_tx_body.findall(f".//{{{DML_NS}}}t")
|
||
)
|
||
field_text = (
|
||
"‹#›"
|
||
if item.placeholder == "slide-number"
|
||
else source_text or "Date"
|
||
)
|
||
ET.SubElement(field, f"{{{DML_NS}}}t").text = field_text
|
||
else:
|
||
run = ET.SubElement(paragraph, f"{{{DML_NS}}}r")
|
||
if source_run_pr is not None:
|
||
run.append(ET.fromstring(ET.tostring(source_run_pr, encoding="utf-8")))
|
||
ET.SubElement(run, f"{{{DML_NS}}}t").text = _TEMPLATE_PLACEHOLDER_PROMPTS.get(
|
||
item.placeholder or "",
|
||
"Click to add content",
|
||
)
|
||
ET.SubElement(paragraph, f"{{{DML_NS}}}endParaRPr", {"lang": "en-US"})
|
||
return tx_body
|
||
|
||
|
||
def _set_placeholder_no_inherited_bullets(
|
||
shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
) -> None:
|
||
"""Keep prose bullet-free while preserving explicit subtitle bullets."""
|
||
if item.placeholder not in {"body", "subtitle"}:
|
||
return
|
||
tx_body = shape.find(f"{{{PML_NS}}}txBody")
|
||
if tx_body is None:
|
||
return
|
||
for paragraph in tx_body.findall(f"{{{DML_NS}}}p"):
|
||
paragraph_props = paragraph.find(f"{{{DML_NS}}}pPr")
|
||
if paragraph_props is None:
|
||
paragraph_props = ET.Element(f"{{{DML_NS}}}pPr")
|
||
paragraph.insert(0, paragraph_props)
|
||
_set_no_bullet_paragraph_properties(
|
||
paragraph_props,
|
||
replace_existing=item.placeholder == "body",
|
||
)
|
||
|
||
|
||
def _set_placeholder_theme_font_role(
|
||
shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
theme_font_spec: ThemeFontSpec | None,
|
||
) -> None:
|
||
"""Force semantic text placeholders onto the correct theme font role."""
|
||
if theme_font_spec is None:
|
||
return
|
||
if item.placeholder == "title":
|
||
prefix = "+mj"
|
||
elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
|
||
prefix = "+mn"
|
||
else:
|
||
return
|
||
for props_tag in ("rPr", "defRPr", "endParaRPr"):
|
||
for props in shape.iter(f"{{{DML_NS}}}{props_tag}"):
|
||
for font_tag, suffix in (("latin", "lt"), ("ea", "ea"), ("cs", "cs")):
|
||
font = props.find(f"{{{DML_NS}}}{font_tag}")
|
||
if font is not None:
|
||
font.set("typeface", f"{prefix}-{suffix}")
|
||
|
||
|
||
def _layout_placeholder_shape(
|
||
source_shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
placeholder_idx: int | None,
|
||
theme_font_spec: ThemeFontSpec | None = None,
|
||
) -> ET.Element:
|
||
"""Build one reusable p:sp placeholder from a prototype slide object."""
|
||
placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
|
||
if placeholder_type is None:
|
||
raise TemplateStructureError(
|
||
f"Unsupported placeholder type: {item.placeholder!r}"
|
||
)
|
||
bounds = _shape_bounds_emu(source_shape, item.placeholder_bounds)
|
||
shape = ET.Element(f"{{{PML_NS}}}sp")
|
||
nv_sp_pr = ET.SubElement(shape, f"{{{PML_NS}}}nvSpPr")
|
||
ET.SubElement(
|
||
nv_sp_pr,
|
||
f"{{{PML_NS}}}cNvPr",
|
||
{"id": "2", "name": f"{item.element_id} Placeholder"},
|
||
)
|
||
c_nv_sp_pr = ET.SubElement(nv_sp_pr, f"{{{PML_NS}}}cNvSpPr")
|
||
ET.SubElement(c_nv_sp_pr, f"{{{DML_NS}}}spLocks", {"noGrp": "1"})
|
||
nv_pr = ET.SubElement(nv_sp_pr, f"{{{PML_NS}}}nvPr")
|
||
placeholder_attrs = {"type": placeholder_type}
|
||
if placeholder_idx is not None:
|
||
placeholder_attrs["idx"] = str(placeholder_idx)
|
||
elif item.placeholder != "title":
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} requires an idx"
|
||
)
|
||
ET.SubElement(nv_pr, f"{{{PML_NS}}}ph", placeholder_attrs)
|
||
|
||
source_sp_pr = (
|
||
source_shape.find(f"{{{PML_NS}}}spPr")
|
||
if source_shape.tag == f"{{{PML_NS}}}sp"
|
||
else None
|
||
)
|
||
if source_sp_pr is not None:
|
||
sp_pr = ET.fromstring(ET.tostring(source_sp_pr, encoding="utf-8"))
|
||
else:
|
||
sp_pr = ET.Element(f"{{{PML_NS}}}spPr")
|
||
geometry = ET.SubElement(sp_pr, f"{{{DML_NS}}}prstGeom", {"prst": "rect"})
|
||
ET.SubElement(geometry, f"{{{DML_NS}}}avLst")
|
||
ET.SubElement(sp_pr, f"{{{DML_NS}}}noFill")
|
||
line = ET.SubElement(sp_pr, f"{{{DML_NS}}}ln")
|
||
ET.SubElement(line, f"{{{DML_NS}}}noFill")
|
||
_replace_shape_xfrm(sp_pr, bounds)
|
||
shape.append(sp_pr)
|
||
shape.append(_placeholder_text_body(source_shape, item))
|
||
_set_placeholder_theme_font_role(shape, item, theme_font_spec)
|
||
return shape
|
||
|
||
|
||
def _placeholder_binding_proxy(
|
||
layout_placeholder: ET.Element,
|
||
item: TemplateElementSpec,
|
||
) -> ET.Element:
|
||
"""Bind a Layout slot invisibly while leaving its visible content ordinary.
|
||
|
||
An unbound object placeholder can leak its inherited empty frame into a
|
||
finished Slide in non-PowerPoint renderers. A hidden matching proxy suppresses
|
||
that inheritance. The zero-width transparent run avoids a LibreOffice empty-
|
||
placeholder black fill without adding visible content.
|
||
"""
|
||
proxy = ET.fromstring(ET.tostring(layout_placeholder, encoding="utf-8"))
|
||
c_nv_pr = next(proxy.iter(f"{{{PML_NS}}}cNvPr"), None)
|
||
if c_nv_pr is None:
|
||
raise TemplateStructureError(
|
||
f"Cannot create placeholder binding for {item.element_id!r}: "
|
||
"p:cNvPr is missing"
|
||
)
|
||
placeholder = proxy.find(f".//{{{PML_NS}}}ph")
|
||
if placeholder is None:
|
||
raise TemplateStructureError(
|
||
f"Cannot create placeholder binding for {item.element_id!r}: "
|
||
"p:ph is missing"
|
||
)
|
||
c_nv_pr.set(
|
||
"name",
|
||
"Placeholder Binding "
|
||
f"{placeholder.get('type', 'body')} {placeholder.get('idx', '0')}",
|
||
)
|
||
c_nv_pr.set("hidden", "1")
|
||
tx_body = proxy.find(f"{{{PML_NS}}}txBody")
|
||
if tx_body is None:
|
||
raise TemplateStructureError(
|
||
f"Cannot create placeholder binding for {item.element_id!r}: "
|
||
"p:txBody is missing"
|
||
)
|
||
for child in list(tx_body):
|
||
if child.tag == f"{{{DML_NS}}}p":
|
||
tx_body.remove(child)
|
||
paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
|
||
run = ET.SubElement(paragraph, f"{{{DML_NS}}}r")
|
||
run_props = ET.SubElement(
|
||
run,
|
||
f"{{{DML_NS}}}rPr",
|
||
{"lang": "en-US", "sz": "100"},
|
||
)
|
||
solid_fill = ET.SubElement(run_props, f"{{{DML_NS}}}solidFill")
|
||
color = ET.SubElement(solid_fill, f"{{{DML_NS}}}srgbClr", {"val": "FFFFFF"})
|
||
ET.SubElement(color, f"{{{DML_NS}}}alpha", {"val": "0"})
|
||
ET.SubElement(run, f"{{{DML_NS}}}t").text = "\u200b"
|
||
ET.SubElement(
|
||
paragraph,
|
||
f"{{{DML_NS}}}endParaRPr",
|
||
{"lang": "en-US"},
|
||
)
|
||
return proxy
|
||
|
||
|
||
def _patch_slide_placeholder(
|
||
shape: ET.Element,
|
||
item: TemplateElementSpec,
|
||
placeholder_idx: int | None,
|
||
placeholder_type: str | None = None,
|
||
theme_font_spec: ThemeFontSpec | None = None,
|
||
) -> None:
|
||
resolved_type = placeholder_type or TEMPLATE_PLACEHOLDER_TYPES.get(
|
||
item.placeholder or ""
|
||
)
|
||
if resolved_type is None:
|
||
raise TemplateStructureError(
|
||
f"Unsupported placeholder type: {item.placeholder!r}"
|
||
)
|
||
nv_paths = {
|
||
f"{{{PML_NS}}}sp": f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr",
|
||
f"{{{PML_NS}}}pic": f"{{{PML_NS}}}nvPicPr/{{{PML_NS}}}nvPr",
|
||
f"{{{PML_NS}}}graphicFrame": (
|
||
f"{{{PML_NS}}}nvGraphicFramePr/{{{PML_NS}}}nvPr"
|
||
),
|
||
}
|
||
nv_path = nv_paths.get(shape.tag)
|
||
if nv_path is None:
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} converted to unsupported "
|
||
f"DrawingML element {shape.tag.rsplit('}', 1)[-1]!r}; text/picture/"
|
||
"native chart/table placeholders must remain one top-level object"
|
||
)
|
||
nv_pr = shape.find(nv_path)
|
||
if nv_pr is None:
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} has no non-visual properties"
|
||
)
|
||
_set_placeholder_no_inherited_bullets(shape, item)
|
||
_set_placeholder_theme_font_role(shape, item, theme_font_spec)
|
||
for existing in list(nv_pr):
|
||
if existing.tag == f"{{{PML_NS}}}ph":
|
||
nv_pr.remove(existing)
|
||
placeholder_attrs: dict[str, str] = {}
|
||
if (
|
||
placeholder_type is not None
|
||
or resolved_type != "obj"
|
||
or placeholder_idx is None
|
||
):
|
||
placeholder_attrs["type"] = resolved_type
|
||
if placeholder_idx is not None:
|
||
placeholder_attrs["idx"] = str(placeholder_idx)
|
||
ph = ET.Element(f"{{{PML_NS}}}ph", placeholder_attrs)
|
||
ext_tag = f"{{{PML_NS}}}extLst"
|
||
insert_at = next(
|
||
(idx for idx, child in enumerate(nv_pr) if child.tag == ext_tag),
|
||
len(nv_pr),
|
||
)
|
||
nv_pr.insert(insert_at, ph)
|
||
|
||
|
||
def _set_template_layout_header_footer(
|
||
layout_path: Path,
|
||
placeholders: tuple[TemplateElementSpec, ...],
|
||
) -> None:
|
||
"""Enable declared footer fields for slides newly created from the layout."""
|
||
kinds = {item.placeholder for item in placeholders}
|
||
if not kinds.intersection({"date", "footer", "slide-number"}):
|
||
return
|
||
tree = ET.parse(layout_path)
|
||
root = tree.getroot()
|
||
hf = root.find(f"{{{PML_NS}}}hf")
|
||
if hf is None:
|
||
hf = ET.Element(f"{{{PML_NS}}}hf")
|
||
trailing_tags = {
|
||
f"{{{PML_NS}}}timing",
|
||
f"{{{PML_NS}}}transition",
|
||
f"{{{PML_NS}}}extLst",
|
||
}
|
||
insert_at = next(
|
||
(idx for idx, child in enumerate(root) if child.tag in trailing_tags),
|
||
len(root),
|
||
)
|
||
root.insert(insert_at, hf)
|
||
hf.set("hdr", "0")
|
||
hf.set("dt", "1" if "date" in kinds else "0")
|
||
hf.set("ftr", "1" if "footer" in kinds else "0")
|
||
hf.set("sldNum", "1" if "slide-number" in kinds else "0")
|
||
_write_xml_tree(layout_path, tree)
|
||
|
||
|
||
def _apply_explicit_layout_structure(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
specs: list[TemplateSlideSpec],
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
theme_font_spec: ThemeFontSpec | None,
|
||
*,
|
||
use_layout_placeholder_frames: bool = False,
|
||
verbose: bool = False,
|
||
) -> tuple[
|
||
dict[str, str | None],
|
||
dict[str, tuple[str, ...]],
|
||
dict[str, str],
|
||
dict[str, str],
|
||
]:
|
||
"""Materialize explicit SVG master/layout/placeholder metadata into OOXML."""
|
||
master_parts_by_key = _assign_structured_masters(
|
||
extract_dir,
|
||
structure,
|
||
specs,
|
||
)
|
||
states = _template_runtime_slides(extract_dir, specs, conversion_traces)
|
||
states_by_slide = {state.spec.slide_num: state for state in states}
|
||
slide_size_emu = _presentation_slide_size_emu(extract_dir)
|
||
|
||
expected_backgrounds: dict[str, str | None] = {}
|
||
expected_shape_rosters: dict[str, tuple[str, ...]] = {}
|
||
states_by_master: dict[str, list[_TemplateRuntimeSlide]] = {}
|
||
for state in states:
|
||
master_part = master_parts_by_key[state.spec.master_key]
|
||
states_by_master.setdefault(master_part, []).append(state)
|
||
master_shape_count = 0
|
||
for master_part, master_states in states_by_master.items():
|
||
master_path = extract_dir / master_part
|
||
master_rels_path = _relationships_path_for_part(extract_dir, master_part)
|
||
expected_backgrounds[master_part] = _extract_slide_background_xml(
|
||
master_path.read_text(encoding="utf-8")
|
||
)
|
||
_clear_master_placeholder_shapes(master_path)
|
||
master_items = master_states[0].spec.master_elements
|
||
for item in master_items:
|
||
background_xml = _move_template_static_shape(
|
||
master_states,
|
||
item,
|
||
master_path,
|
||
master_rels_path,
|
||
slide_size_emu,
|
||
)
|
||
if background_xml is not None:
|
||
expected_backgrounds[master_part] = background_xml
|
||
master_shape_count += 1
|
||
|
||
specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
|
||
for spec in specs:
|
||
specs_by_layout.setdefault(spec.layout_key, []).append(spec)
|
||
placeholder_count = 0
|
||
layout_shape_count = 0
|
||
created_layout_parts: set[str] = set()
|
||
layout_parts_by_key: dict[str, str] = {}
|
||
for layout_key, layout_specs in specs_by_layout.items():
|
||
layout_states = [states_by_slide[spec.slide_num] for spec in layout_specs]
|
||
master_parts = {
|
||
structure.slide_master_part(spec.slide_num) for spec in layout_specs
|
||
}
|
||
if len(master_parts) != 1:
|
||
raise TemplateStructureError(
|
||
f"Layout {layout_key!r} spans multiple slide masters; use distinct "
|
||
"layout keys per master"
|
||
)
|
||
master_part = next(iter(master_parts))
|
||
prototype = layout_specs[0]
|
||
base_target = structure.slide_layout_target(prototype.slide_num)
|
||
base_layout_part = _resolve_package_target(
|
||
f"ppt/slides/slide{prototype.slide_num}.xml",
|
||
base_target,
|
||
)
|
||
layout_target, layout_part = _create_custom_layout(
|
||
extract_dir,
|
||
master_part,
|
||
base_layout_part,
|
||
prototype.layout_name,
|
||
show_master_shapes=prototype.layout_show_master_shapes,
|
||
)
|
||
layout_path = extract_dir / layout_part
|
||
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
|
||
created_layout_parts.add(layout_part)
|
||
layout_parts_by_key[layout_key] = layout_part
|
||
|
||
placeholder_bindings = {
|
||
binding.element.element_id: binding
|
||
for binding in template_placeholder_bindings(prototype)
|
||
}
|
||
for item in prototype.elements:
|
||
if item.layer == "layout":
|
||
background_xml = _move_template_static_shape(
|
||
layout_states,
|
||
item,
|
||
layout_path,
|
||
layout_rels_path,
|
||
slide_size_emu,
|
||
)
|
||
if background_xml is not None:
|
||
expected_backgrounds[layout_part] = background_xml
|
||
layout_shape_count += 1
|
||
continue
|
||
if not item.placeholder:
|
||
continue
|
||
proxy_binding = is_proxy_placeholder(item)
|
||
if proxy_binding:
|
||
placeholder_shapes = [
|
||
_template_shape_for_item(state, item)
|
||
for state in layout_states
|
||
]
|
||
if any(shape is None for shape in placeholder_shapes):
|
||
raise TemplateStructureError(
|
||
f"Placeholder {item.element_id!r} cannot be a slide background"
|
||
)
|
||
resolved_shapes = [
|
||
shape for shape in placeholder_shapes if shape is not None
|
||
]
|
||
else:
|
||
resolved_shapes = [
|
||
_unwrap_placeholder_carrier(state, item)
|
||
for state in layout_states
|
||
]
|
||
prototype_shape = resolved_shapes[0]
|
||
binding = placeholder_bindings[item.element_id]
|
||
assigned_idx = binding.assigned_idx
|
||
layout_placeholder = _layout_placeholder_shape(
|
||
prototype_shape,
|
||
item,
|
||
assigned_idx,
|
||
theme_font_spec,
|
||
)
|
||
_append_shape_to_part(layout_path, layout_placeholder)
|
||
for state, shape in zip(layout_states, resolved_shapes):
|
||
if not proxy_binding:
|
||
_patch_slide_placeholder(
|
||
shape,
|
||
item,
|
||
assigned_idx,
|
||
theme_font_spec=theme_font_spec,
|
||
)
|
||
if use_layout_placeholder_frames:
|
||
_apply_layout_frame_to_placeholder_carrier(shape, item)
|
||
_set_shape_name(
|
||
shape,
|
||
f"{item.element_id} Placeholder Carrier",
|
||
)
|
||
else:
|
||
_set_shape_name(
|
||
shape,
|
||
f"{item.element_id} Proxy Content",
|
||
)
|
||
_append_shape_to_runtime_slide(
|
||
state,
|
||
_placeholder_binding_proxy(layout_placeholder, item),
|
||
)
|
||
placeholder_count += 1
|
||
|
||
_set_template_layout_header_footer(layout_path, prototype.placeholders)
|
||
for state in layout_states:
|
||
_set_slide_layout_target(state.rels_path, layout_target)
|
||
|
||
slide_backgrounds = _apply_template_slide_backgrounds(
|
||
states,
|
||
slide_size_emu,
|
||
)
|
||
expected_backgrounds.update(slide_backgrounds)
|
||
for state in states:
|
||
state.root.set(
|
||
"showMasterSp",
|
||
"1" if state.spec.slide_show_inherited_shapes else "0",
|
||
)
|
||
_write_xml_tree(state.slide_path, state.tree)
|
||
expected_backgrounds.setdefault(
|
||
f"ppt/slides/slide{state.spec.slide_num}.xml",
|
||
None,
|
||
)
|
||
expected_shape_rosters[
|
||
f"ppt/slides/slide{state.spec.slide_num}.xml"
|
||
] = _top_level_shape_name_roster(state.root)
|
||
for part in states_by_master:
|
||
expected_backgrounds.setdefault(
|
||
part,
|
||
_extract_slide_background_xml(
|
||
(extract_dir / part).read_text(encoding="utf-8")
|
||
),
|
||
)
|
||
for part in created_layout_parts:
|
||
expected_backgrounds.setdefault(part, None)
|
||
for part in (*states_by_master, *created_layout_parts):
|
||
expected_shape_rosters[part] = _top_level_shape_name_roster(
|
||
ET.parse(extract_dir / part).getroot()
|
||
)
|
||
|
||
if verbose:
|
||
print(
|
||
" Explicit Layout structure: "
|
||
f"{len(states_by_master)} master(s), "
|
||
f"{len(specs_by_layout)} layout(s), "
|
||
f"{master_shape_count} master element(s), "
|
||
f"{layout_shape_count} layout element(s), "
|
||
f"{len(slide_backgrounds)} slide background(s), "
|
||
f"{placeholder_count} placeholder definition(s)"
|
||
)
|
||
return (
|
||
expected_backgrounds,
|
||
expected_shape_rosters,
|
||
layout_parts_by_key,
|
||
master_parts_by_key,
|
||
)
|
||
|
||
|
||
def _apply_preserved_structure(
|
||
extract_dir: Path,
|
||
specs: list[TemplateSlideSpec],
|
||
contract: NativeStructureContract,
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> None:
|
||
"""Drop preview-only inherited layers and bind content to source placeholders."""
|
||
states = _template_runtime_slides(extract_dir, specs, conversion_traces)
|
||
removed_preview_shapes = 0
|
||
removed_preview_backgrounds = 0
|
||
placeholder_count = 0
|
||
for state in states:
|
||
removed_background = False
|
||
for item in state.spec.elements:
|
||
if item.layer not in {"master", "layout"}:
|
||
continue
|
||
shape = _template_shape_for_item(state, item)
|
||
if shape is not None:
|
||
_remove_template_shape(state, shape)
|
||
removed_preview_shapes += 1
|
||
continue
|
||
if removed_background:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: multiple inherited preview "
|
||
"backgrounds resolved to one slide background"
|
||
)
|
||
common_slide = state.root.find(f"{{{PML_NS}}}cSld")
|
||
background = (
|
||
common_slide.find(f"{{{PML_NS}}}bg")
|
||
if common_slide is not None
|
||
else None
|
||
)
|
||
if common_slide is None or background is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: inherited preview background "
|
||
"did not produce a removable slide background"
|
||
)
|
||
common_slide.remove(background)
|
||
removed_background = True
|
||
removed_preview_backgrounds += 1
|
||
|
||
layout = contract.layout(state.spec.layout_key)
|
||
for item, source_placeholder in match_native_placeholders(state.spec, layout):
|
||
shape = _template_shape_for_item(state, item)
|
||
if shape is None:
|
||
raise TemplateStructureError(
|
||
f"{state.spec.svg_path.name}: placeholder {item.element_id!r} "
|
||
"cannot resolve to a slide background"
|
||
)
|
||
_patch_slide_placeholder(
|
||
shape,
|
||
item,
|
||
source_placeholder.idx,
|
||
source_placeholder.placeholder_type,
|
||
)
|
||
placeholder_count += 1
|
||
_write_xml_tree(state.slide_path, state.tree)
|
||
|
||
if verbose:
|
||
print(
|
||
" Preserved structure: "
|
||
f"{len({spec.layout_key for spec in specs})} source layout(s), "
|
||
f"{removed_preview_shapes} preview shape(s) removed, "
|
||
f"{removed_preview_backgrounds} preview background(s) removed, "
|
||
f"{placeholder_count} source placeholder binding(s)"
|
||
)
|
||
|
||
|
||
def _promote_common_chrome_shapes_to_masters(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
slide_count: int,
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Promote explicit repeated chrome SVG ids to their shared master."""
|
||
if not conversion_traces:
|
||
return 0
|
||
trace_by_slide = {
|
||
int(trace.get("slide_num", 0)): trace
|
||
for trace in conversion_traces
|
||
if trace.get("slide_num") is not None
|
||
}
|
||
if len(trace_by_slide) < slide_count:
|
||
return 0
|
||
|
||
slides_by_master: dict[str, list[int]] = {}
|
||
for slide_num in range(1, slide_count + 1):
|
||
master_part = structure.slide_master_part(slide_num)
|
||
slides_by_master.setdefault(master_part, []).append(slide_num)
|
||
|
||
promoted = 0
|
||
promoted_roles = 0
|
||
for master_part, slide_nums in slides_by_master.items():
|
||
if len(slide_nums) < 2:
|
||
continue
|
||
slide_state: dict[int, dict[str, Any]] = {}
|
||
for slide_num in slide_nums:
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
rels_path = extract_dir / "ppt" / "slides" / "_rels" / f"slide{slide_num}.xml.rels"
|
||
tree = ET.parse(slide_path)
|
||
root = tree.getroot()
|
||
slide_state[slide_num] = {
|
||
"path": slide_path,
|
||
"rels": _read_relationships(rels_path),
|
||
"root": root,
|
||
"shapes": _top_level_shapes_by_id(root),
|
||
"timing_shape_ids": _timing_shape_ids(root),
|
||
"tokens": _trace_chrome_shape_ids(trace_by_slide.get(slide_num)),
|
||
"tree": tree,
|
||
}
|
||
|
||
# Per token, find the strict-majority identical variant. Slides
|
||
# outside every dominant set become cover-layout minority slides.
|
||
candidate_sets: dict[str, dict[int, str]] = {}
|
||
all_tokens = sorted({
|
||
token
|
||
for state in slide_state.values()
|
||
for token in state["tokens"]
|
||
})
|
||
for token in all_tokens:
|
||
carriers: dict[int, str] = {}
|
||
canonical_by_slide: dict[int, bytes] = {}
|
||
for slide_num in slide_nums:
|
||
state = slide_state[slide_num]
|
||
shape_ids = state["tokens"].get(token, [])
|
||
if len(shape_ids) != 1:
|
||
continue
|
||
shape_id = shape_ids[0]
|
||
shape = state["shapes"].get(shape_id)
|
||
if shape is None:
|
||
continue
|
||
if shape_id in state["timing_shape_ids"]:
|
||
continue
|
||
if not _shape_relationships_supported(shape, state["rels"]):
|
||
continue
|
||
carriers[slide_num] = shape_id
|
||
canonical_by_slide[slide_num] = _canonical_shape_xml(
|
||
shape,
|
||
state["rels"],
|
||
)
|
||
dominant_xml, dominant_slides = _dominant_variant(canonical_by_slide)
|
||
if dominant_xml is None:
|
||
continue
|
||
if not _is_strict_majority(len(dominant_slides), len(slide_nums)):
|
||
continue
|
||
candidate_sets[token] = {
|
||
slide_num: carriers[slide_num] for slide_num in dominant_slides
|
||
}
|
||
|
||
if not candidate_sets:
|
||
continue
|
||
content_slides = sorted(set.intersection(
|
||
*(set(slides) for slides in candidate_sets.values())
|
||
))
|
||
if not _is_strict_majority(len(content_slides), len(slide_nums)):
|
||
continue
|
||
|
||
promotions: list[tuple[str, dict[int, str]]] = []
|
||
claimed_shape_ids: dict[int, set[str]] = {
|
||
slide_num: set() for slide_num in content_slides
|
||
}
|
||
for token in sorted(candidate_sets):
|
||
shape_ids_by_slide = {
|
||
slide_num: candidate_sets[token][slide_num]
|
||
for slide_num in content_slides
|
||
}
|
||
# A flattened nested chrome group can emit several semantic trace
|
||
# ids for the same generated DrawingML shape. Claim it once.
|
||
if any(
|
||
shape_ids_by_slide[slide_num] in claimed_shape_ids[slide_num]
|
||
for slide_num in content_slides
|
||
):
|
||
continue
|
||
for slide_num, shape_id in shape_ids_by_slide.items():
|
||
claimed_shape_ids[slide_num].add(shape_id)
|
||
promotions.append((token, shape_ids_by_slide))
|
||
|
||
if not promotions:
|
||
continue
|
||
|
||
# Master shapes always render behind slide-local shapes. Preserve the
|
||
# original z-order by promoting only a common leading chrome prefix;
|
||
# overlay headers/footers remain slide-local.
|
||
token_by_shape_id = {
|
||
slide_num: {
|
||
shape_ids[slide_num]: token
|
||
for token, shape_ids in promotions
|
||
}
|
||
for slide_num in content_slides
|
||
}
|
||
leading_token_orders: list[list[str]] = []
|
||
for slide_num in content_slides:
|
||
order: list[str] = []
|
||
for shape_id in slide_state[slide_num]["shapes"]:
|
||
token = token_by_shape_id[slide_num].get(shape_id)
|
||
if token is None:
|
||
break
|
||
order.append(token)
|
||
leading_token_orders.append(order)
|
||
|
||
safe_tokens = list(leading_token_orders[0])
|
||
for order in leading_token_orders[1:]:
|
||
common_length = 0
|
||
for expected, actual in zip(safe_tokens, order):
|
||
if expected != actual:
|
||
break
|
||
common_length += 1
|
||
safe_tokens = safe_tokens[:common_length]
|
||
if not safe_tokens:
|
||
break
|
||
promotion_by_token = {token: shape_ids for token, shape_ids in promotions}
|
||
promotions = [
|
||
(token, promotion_by_token[token])
|
||
for token in safe_tokens
|
||
]
|
||
|
||
if not promotions:
|
||
continue
|
||
|
||
master_path = extract_dir / master_part
|
||
master_rels_path = _relationships_path_for_part(extract_dir, master_part)
|
||
for _token, shape_ids_by_slide in promotions:
|
||
first_slide = content_slides[0]
|
||
first_state = slide_state[first_slide]
|
||
shape = first_state["shapes"][shape_ids_by_slide[first_slide]]
|
||
master_shape = _copy_shape_relationships_to_master(
|
||
shape,
|
||
first_state["rels"],
|
||
master_rels_path,
|
||
)
|
||
_append_shape_to_master(master_path, master_shape)
|
||
promoted_roles += 1
|
||
|
||
for slide_num, shape_id in shape_ids_by_slide.items():
|
||
state = slide_state[slide_num]
|
||
shape_to_remove = state["shapes"].get(shape_id)
|
||
sp_tree = state["root"].find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
|
||
if sp_tree is not None and shape_to_remove is not None:
|
||
sp_tree.remove(shape_to_remove)
|
||
promoted += 1
|
||
|
||
for slide_num in content_slides:
|
||
state = slide_state[slide_num]
|
||
_write_xml_tree(state["path"], state["tree"])
|
||
|
||
# Minority slides (covers, section pages) keep every shape
|
||
# slide-local and move to a Cover layout that hides the newly
|
||
# promoted master chrome, so their rendering never changes.
|
||
minority_slides = [
|
||
slide_num for slide_num in slide_nums
|
||
if slide_num not in set(content_slides)
|
||
]
|
||
if minority_slides:
|
||
first_minority_rels = (
|
||
extract_dir / "ppt" / "slides" / "_rels"
|
||
/ f"slide{minority_slides[0]}.xml.rels"
|
||
)
|
||
base_target = _find_relationship_target(
|
||
first_minority_rels, SLIDE_LAYOUT_REL_TYPE
|
||
)
|
||
if not base_target:
|
||
raise RuntimeError(
|
||
f"Slide {minority_slides[0]} has no slide layout relationship"
|
||
)
|
||
base_layout_part = _resolve_package_target(
|
||
f"ppt/slides/slide{minority_slides[0]}.xml", base_target
|
||
)
|
||
cover_target = _create_cover_layout(
|
||
extract_dir, master_part, base_layout_part
|
||
)
|
||
for slide_num in minority_slides:
|
||
rels_path = (
|
||
extract_dir / "ppt" / "slides" / "_rels"
|
||
/ f"slide{slide_num}.xml.rels"
|
||
)
|
||
_set_slide_layout_target(rels_path, cover_target)
|
||
if verbose:
|
||
print(
|
||
" Baseline cover layout: "
|
||
f"{len(minority_slides)} slide(s) keep slide-local chrome"
|
||
)
|
||
|
||
if verbose and promoted:
|
||
print(
|
||
" Baseline master chrome: "
|
||
f"promoted {promoted} slide shape(s) across {promoted_roles} shared object(s)"
|
||
)
|
||
return promoted
|
||
|
||
|
||
_PAGE_NUMBER_TOKENS = {"pagenumber", "pagenum", "slidenumber"}
|
||
|
||
|
||
def _first_slide_number(extract_dir: Path) -> int:
|
||
"""Read firstSlideNum from presentation.xml (defaults to 1)."""
|
||
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
||
try:
|
||
root = ET.parse(presentation_path).getroot()
|
||
except (OSError, ET.ParseError):
|
||
return 1
|
||
raw = root.attrib.get("firstSlideNum")
|
||
if raw is None:
|
||
return 1
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
return 1
|
||
|
||
|
||
def _shape_with_id(root: ET.Element, shape_id: str) -> ET.Element | None:
|
||
"""Find a p:sp anywhere in the slide tree by its cNvPr id."""
|
||
for shape in root.iter(f"{{{PML_NS}}}sp"):
|
||
cnv = shape.find(f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}cNvPr")
|
||
if cnv is not None and cnv.attrib.get("id") == shape_id:
|
||
return shape
|
||
return None
|
||
|
||
|
||
def _replace_literal_run_with_slidenum_field(
|
||
shape: ET.Element,
|
||
expected_text: str,
|
||
field_guid: str,
|
||
) -> bool:
|
||
"""Swap a single literal page-number run for an a:fld slidenum field."""
|
||
tx_body = shape.find(f"{{{PML_NS}}}txBody")
|
||
if tx_body is None:
|
||
return False
|
||
a_t = f"{{{DML_NS}}}t"
|
||
total_text = "".join(t.text or "" for t in tx_body.iter(a_t))
|
||
if total_text.strip() != expected_text:
|
||
return False
|
||
text_runs = [
|
||
(paragraph, run)
|
||
for paragraph in tx_body.iter(f"{{{DML_NS}}}p")
|
||
for run in paragraph.findall(f"{{{DML_NS}}}r")
|
||
if (run.findtext(a_t) or "").strip()
|
||
]
|
||
if len(text_runs) != 1:
|
||
return False
|
||
paragraph, run = text_runs[0]
|
||
if (run.findtext(a_t) or "").strip() != expected_text:
|
||
return False
|
||
|
||
fld = ET.Element(f"{{{DML_NS}}}fld", {"id": field_guid, "type": "slidenum"})
|
||
r_pr = run.find(f"{{{DML_NS}}}rPr")
|
||
if r_pr is not None:
|
||
fld.append(ET.fromstring(ET.tostring(r_pr, encoding="utf-8")))
|
||
fld_text = ET.SubElement(fld, a_t)
|
||
fld_text.text = expected_text
|
||
index = list(paragraph).index(run)
|
||
paragraph.remove(run)
|
||
paragraph.insert(index, fld)
|
||
return True
|
||
|
||
|
||
def _convert_page_number_texts_to_fields(
|
||
extract_dir: Path,
|
||
slide_count: int,
|
||
conversion_traces: list[dict[str, Any]] | None,
|
||
*,
|
||
context: str = "Baseline",
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Replace literal page-number chrome text with auto-updating fields.
|
||
|
||
Only converts when the traced pageNumber/slideNumber shape's whole text
|
||
equals the slide's expected display number (honoring firstSlideNum), so
|
||
schemes like content-only numbering keep their literal text untouched.
|
||
"""
|
||
if not conversion_traces:
|
||
return 0
|
||
trace_by_slide = {
|
||
int(trace.get("slide_num", 0)): trace
|
||
for trace in conversion_traces
|
||
if trace.get("slide_num") is not None
|
||
}
|
||
first_slide_number = _first_slide_number(extract_dir)
|
||
field_guid = f"{{{str(uuid.uuid4()).upper()}}}"
|
||
|
||
converted = 0
|
||
for slide_num in range(1, slide_count + 1):
|
||
tokens = _trace_chrome_shape_ids(trace_by_slide.get(slide_num))
|
||
shape_ids = sorted({
|
||
shape_id
|
||
for token, ids in tokens.items()
|
||
if token in _PAGE_NUMBER_TOKENS
|
||
for shape_id in ids
|
||
})
|
||
if len(shape_ids) != 1:
|
||
continue
|
||
slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
|
||
tree = ET.parse(slide_path)
|
||
shape = _shape_with_id(tree.getroot(), shape_ids[0])
|
||
if shape is None:
|
||
continue
|
||
expected_text = str(first_slide_number + slide_num - 1)
|
||
if _replace_literal_run_with_slidenum_field(shape, expected_text, field_guid):
|
||
_write_xml_tree(slide_path, tree)
|
||
converted += 1
|
||
|
||
if verbose and converted:
|
||
print(
|
||
f" {context} slide-number fields: "
|
||
f"converted {converted} page number(s)"
|
||
)
|
||
return converted
|
||
|
||
|
||
def _remove_relationship(rels_path: Path, rel_id: str) -> None:
|
||
"""Remove one relationship entry by rId."""
|
||
rels_content = rels_path.read_text(encoding="utf-8")
|
||
pattern = re.compile(
|
||
rf'[ \t]*<Relationship\b[^>]*\bId="{re.escape(rel_id)}"[^>]*/>[ \t]*\n?'
|
||
)
|
||
new_content, removed = pattern.subn("", rels_content, count=1)
|
||
if not removed:
|
||
raise RuntimeError(f"Relationship {rel_id} not found in {rels_path}")
|
||
rels_path.write_text(new_content, encoding="utf-8")
|
||
|
||
|
||
def _remove_content_type_override(content_types_path: Path, part_name: str) -> None:
|
||
"""Remove the Override content-type entry for a deleted package part."""
|
||
normalized = "/" + part_name.lstrip("/")
|
||
content = content_types_path.read_text(encoding="utf-8")
|
||
pattern = re.compile(
|
||
rf'[ \t]*<Override\b[^>]*\bPartName="{re.escape(normalized)}"[^>]*/>[ \t]*\n?'
|
||
)
|
||
new_content, removed = pattern.subn("", content, count=1)
|
||
if removed:
|
||
content_types_path.write_text(new_content, encoding="utf-8")
|
||
|
||
|
||
def _remove_trailing_layout_definition_slides(
|
||
extract_dir: Path,
|
||
public_slide_count: int,
|
||
total_slide_count: int,
|
||
) -> int:
|
||
"""Remove internal carrier slides after their Layouts are registered."""
|
||
if total_slide_count <= public_slide_count:
|
||
return 0
|
||
presentation_part = "ppt/presentation.xml"
|
||
presentation_path = extract_dir / presentation_part
|
||
presentation_rels = _relationships_path_for_part(
|
||
extract_dir,
|
||
presentation_part,
|
||
)
|
||
tree = ET.parse(presentation_path)
|
||
root = tree.getroot()
|
||
slide_list = root.find(f"{{{PML_NS}}}sldIdLst")
|
||
if slide_list is None:
|
||
raise RuntimeError("presentation.xml has no p:sldIdLst")
|
||
entries_by_rel_id = {
|
||
entry.get(f"{{{REL_NS}}}id", ""): entry
|
||
for entry in slide_list.findall(f"{{{PML_NS}}}sldId")
|
||
}
|
||
relationships = _read_relationships(presentation_rels)
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
removed = 0
|
||
for slide_num in range(public_slide_count + 1, total_slide_count + 1):
|
||
slide_part = f"ppt/slides/slide{slide_num}.xml"
|
||
rel_ids = [
|
||
rel_id
|
||
for rel_id, attrs in relationships.items()
|
||
if attrs.get("Type") == SLIDE_REL_TYPE
|
||
and _resolve_package_target(
|
||
presentation_part,
|
||
attrs.get("Target", ""),
|
||
) == slide_part
|
||
]
|
||
if len(rel_ids) != 1:
|
||
raise RuntimeError(
|
||
f"Internal Layout carrier {slide_part} must have exactly one "
|
||
"Presentation relationship"
|
||
)
|
||
rel_id = rel_ids[0]
|
||
entry = entries_by_rel_id.get(rel_id)
|
||
if entry is None:
|
||
raise RuntimeError(
|
||
f"presentation.xml has no p:sldId entry for {slide_part}"
|
||
)
|
||
slide_list.remove(entry)
|
||
_remove_relationship(presentation_rels, rel_id)
|
||
slide_path = extract_dir / slide_part
|
||
slide_rels = _relationships_path_for_part(extract_dir, slide_part)
|
||
if not slide_path.is_file() or not slide_rels.is_file():
|
||
raise RuntimeError(
|
||
f"Internal Layout carrier package parts are incomplete: {slide_part}"
|
||
)
|
||
slide_path.unlink()
|
||
slide_rels.unlink()
|
||
_remove_content_type_override(content_types_path, slide_part)
|
||
removed += 1
|
||
_write_xml_tree(presentation_path, tree)
|
||
return removed
|
||
|
||
|
||
def _prune_unreferenced_definition_payload_parts(extract_dir: Path) -> int:
|
||
"""Remove generated native/media payload left by deleted carrier slides.
|
||
|
||
Definition-only SVGs are first converted as ordinary slides so their
|
||
reusable structure can be promoted. Removing those internal slides may
|
||
leave chart, workbook, or media parts with no remaining relationship. Run
|
||
an iterative incoming-reference sweep so chart-owned workbooks/styles are
|
||
removed after their orphan chart part and relationship sidecar disappear.
|
||
"""
|
||
candidate_prefixes = (
|
||
"ppt/charts/",
|
||
"ppt/embeddings/",
|
||
"ppt/media/",
|
||
)
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
removed = 0
|
||
while True:
|
||
referenced_parts: set[str] = set()
|
||
for rels_path in extract_dir.rglob("*.rels"):
|
||
rels_rel = rels_path.relative_to(extract_dir).as_posix()
|
||
try:
|
||
root = ET.parse(rels_path).getroot()
|
||
except ET.ParseError as exc:
|
||
raise RuntimeError(
|
||
f"Invalid relationships XML while pruning {rels_rel}: {exc}"
|
||
) from exc
|
||
for elem in root:
|
||
attrs = _relationship_attrs(elem)
|
||
if attrs.get("TargetMode", "").lower() == "external":
|
||
continue
|
||
target = attrs.get("Target")
|
||
if not target:
|
||
continue
|
||
resolved = _resolve_internal_opc_target(rels_rel, target)
|
||
if resolved is not None:
|
||
referenced_parts.add(resolved)
|
||
|
||
orphan_paths: list[tuple[Path, str]] = []
|
||
for path in extract_dir.rglob("*"):
|
||
if not path.is_file():
|
||
continue
|
||
part_name = path.relative_to(extract_dir).as_posix()
|
||
if "/_rels/" in part_name:
|
||
continue
|
||
if not part_name.startswith(candidate_prefixes):
|
||
continue
|
||
canonical = _canonical_opc_part_path(part_name)
|
||
if canonical is not None and canonical not in referenced_parts:
|
||
orphan_paths.append((path, part_name))
|
||
if not orphan_paths:
|
||
break
|
||
|
||
for path, part_name in orphan_paths:
|
||
rels_path = _relationships_path_for_part(extract_dir, part_name)
|
||
if rels_path.is_file():
|
||
rels_path.unlink()
|
||
path.unlink()
|
||
_remove_content_type_override(content_types_path, part_name)
|
||
removed += 1
|
||
return removed
|
||
|
||
|
||
def _prune_unused_slide_layouts(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
slide_count: int,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> int:
|
||
"""Remove base-template slide layouts no generated slide references.
|
||
|
||
The python-pptx base package ships the full Office layout set; unused
|
||
entries only pollute the PowerPoint new-slide picker. Layouts referenced
|
||
by any generated slide are always kept, and a master keeps its layout
|
||
list untouched unless at least one referenced layout remains in it.
|
||
"""
|
||
# Read layout references live: earlier baseline passes may have rebound
|
||
# minority slides to a Cover layout that the initial structure context
|
||
# does not know about.
|
||
referenced_layouts: set[str] = set()
|
||
for slide_num in range(1, slide_count + 1):
|
||
rels_path = (
|
||
extract_dir / "ppt" / "slides" / "_rels" / f"slide{slide_num}.xml.rels"
|
||
)
|
||
target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
|
||
if not target:
|
||
raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
|
||
referenced_layouts.add(
|
||
_resolve_package_target(f"ppt/slides/slide{slide_num}.xml", target)
|
||
)
|
||
|
||
pruned = 0
|
||
content_types_path = extract_dir / "[Content_Types].xml"
|
||
for master_part in sorted(set(structure.slide_master_parts.values())):
|
||
master_path = extract_dir / master_part
|
||
master_rels_path = _relationships_path_for_part(extract_dir, master_part)
|
||
layout_rels = {
|
||
rel_id: _resolve_package_target(master_part, attrs.get("Target", ""))
|
||
for rel_id, attrs in _read_relationships(master_rels_path).items()
|
||
if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE
|
||
}
|
||
if not any(part in referenced_layouts for part in layout_rels.values()):
|
||
continue
|
||
|
||
master_xml = master_path.read_text(encoding="utf-8")
|
||
for rel_id, layout_part in sorted(layout_rels.items()):
|
||
if layout_part in referenced_layouts:
|
||
continue
|
||
entry_re = re.compile(
|
||
rf'[ \t]*<p:sldLayoutId\b[^>]*\br:id="{re.escape(rel_id)}"[^>]*/>[ \t]*\n?'
|
||
)
|
||
master_xml, removed = entry_re.subn("", master_xml, count=1)
|
||
if not removed:
|
||
raise RuntimeError(
|
||
f"Slide master {master_part} has no sldLayoutId entry for {rel_id}"
|
||
)
|
||
_remove_relationship(master_rels_path, rel_id)
|
||
(extract_dir / layout_part).unlink()
|
||
layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
|
||
if layout_rels_path.exists():
|
||
layout_rels_path.unlink()
|
||
_remove_content_type_override(content_types_path, layout_part)
|
||
pruned += 1
|
||
master_path.write_text(master_xml, encoding="utf-8")
|
||
|
||
if verbose and pruned:
|
||
print(f" Layout prune: removed {pruned} unused base layout(s)")
|
||
return pruned
|
||
|
||
|
||
def _flat_structure_name(value: str | None) -> str:
|
||
"""Return one compact package identity for a free-design deck."""
|
||
normalized = " ".join((value or "").split()).strip()
|
||
return (normalized or "Free Design")[:120]
|
||
|
||
|
||
def _flat_placeholder_type(shape: ET.Element) -> str | None:
|
||
"""Return one system placeholder type carried by a top-level shape."""
|
||
if shape.tag != f"{{{PML_NS}}}sp":
|
||
return None
|
||
placeholder = shape.find(
|
||
f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
|
||
)
|
||
return placeholder.get("type") if placeholder is not None else None
|
||
|
||
|
||
def _clean_flat_structure_part(
|
||
part_path: Path,
|
||
name: str,
|
||
*,
|
||
is_layout: bool,
|
||
) -> tuple[int, tuple[str, ...]]:
|
||
"""Keep only standard footer hooks in one project-owned flat shell."""
|
||
try:
|
||
tree = ET.parse(part_path)
|
||
except (OSError, ET.ParseError) as exc:
|
||
raise RuntimeError(
|
||
f"Cannot parse flat structure part {part_path}: {exc}"
|
||
) from exc
|
||
root = tree.getroot()
|
||
common_slide = root.find(f"{{{PML_NS}}}cSld")
|
||
if common_slide is None:
|
||
raise RuntimeError(f"Flat structure part has no p:cSld: {part_path}")
|
||
shape_tree = common_slide.find(f"{{{PML_NS}}}spTree")
|
||
if shape_tree is None:
|
||
raise RuntimeError(f"Flat structure part has no p:spTree: {part_path}")
|
||
|
||
removed = 0
|
||
retained: list[str] = []
|
||
for child in list(shape_tree):
|
||
if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
|
||
continue
|
||
placeholder_type = _flat_placeholder_type(child)
|
||
if placeholder_type in _FLAT_SYSTEM_PLACEHOLDER_TYPES:
|
||
retained.append(placeholder_type)
|
||
continue
|
||
shape_tree.remove(child)
|
||
removed += 1
|
||
for parent in (common_slide, root):
|
||
for extension_list in parent.findall(f"{{{PML_NS}}}extLst"):
|
||
parent.remove(extension_list)
|
||
|
||
common_slide.set("name", name)
|
||
if is_layout:
|
||
root.set("type", "blank")
|
||
root.set("preserve", "1")
|
||
_write_xml_tree(part_path, tree)
|
||
return removed, tuple(retained)
|
||
|
||
|
||
def _name_flat_themes(extract_dir: Path, name: str) -> int:
|
||
"""Replace stock Office theme identities with the current deck identity."""
|
||
theme_paths = sorted((extract_dir / "ppt" / "theme").glob("theme*.xml"))
|
||
if not theme_paths:
|
||
raise RuntimeError("Flat PPTX package has no theme part")
|
||
for theme_path in theme_paths:
|
||
try:
|
||
tree = ET.parse(theme_path)
|
||
except (OSError, ET.ParseError) as exc:
|
||
raise RuntimeError(
|
||
f"Cannot parse flat theme {theme_path}: {exc}"
|
||
) from exc
|
||
root = tree.getroot()
|
||
root.set("name", name)
|
||
for tag in ("clrScheme", "fontScheme", "fmtScheme"):
|
||
scheme = root.find(f".//{{{DML_NS}}}{tag}")
|
||
if scheme is not None:
|
||
scheme.set("name", name)
|
||
_write_xml_tree(theme_path, tree)
|
||
return len(theme_paths)
|
||
|
||
|
||
def _prepare_flat_structure(
|
||
extract_dir: Path,
|
||
structure: PptxStructureContext,
|
||
slide_count: int,
|
||
master_text_style_spec: MasterTextStyleSpec | None,
|
||
structure_name: str | None,
|
||
*,
|
||
verbose: bool = False,
|
||
) -> None:
|
||
"""Materialize one clean current-deck Master and Blank Layout for flat export."""
|
||
pruned = _prune_unused_slide_layouts(
|
||
extract_dir,
|
||
structure,
|
||
slide_count,
|
||
verbose=False,
|
||
)
|
||
live_structure = _read_slide_layout_targets(extract_dir, slide_count)
|
||
layout_parts = {
|
||
_resolve_package_target(
|
||
f"ppt/slides/slide{slide_num}.xml",
|
||
live_structure.slide_layout_target(slide_num),
|
||
)
|
||
for slide_num in range(1, slide_count + 1)
|
||
}
|
||
master_parts = set(live_structure.slide_master_parts.values())
|
||
physical_layouts = {
|
||
str(path.relative_to(extract_dir)).replace("\\", "/")
|
||
for path in (extract_dir / "ppt" / "slideLayouts").glob("slideLayout*.xml")
|
||
}
|
||
physical_masters = {
|
||
str(path.relative_to(extract_dir)).replace("\\", "/")
|
||
for path in (extract_dir / "ppt" / "slideMasters").glob("slideMaster*.xml")
|
||
}
|
||
if len(layout_parts) != 1 or layout_parts != physical_layouts:
|
||
raise RuntimeError(
|
||
"Flat export must retain exactly one slide-referenced Blank Layout"
|
||
)
|
||
if len(master_parts) != 1 or master_parts != physical_masters:
|
||
raise RuntimeError(
|
||
"Flat export must retain exactly one slide-referenced Master"
|
||
)
|
||
|
||
identity = _flat_structure_name(structure_name)
|
||
theme_name = identity
|
||
master_name = f"{identity} — Master"
|
||
layout_name = f"{identity} — Blank"
|
||
master_path = extract_dir / next(iter(master_parts))
|
||
layout_path = extract_dir / next(iter(layout_parts))
|
||
removed_master_shapes, retained_master_placeholders = _clean_flat_structure_part(
|
||
master_path,
|
||
master_name,
|
||
is_layout=False,
|
||
)
|
||
removed_layout_shapes, retained_layout_placeholders = _clean_flat_structure_part(
|
||
layout_path,
|
||
layout_name,
|
||
is_layout=True,
|
||
)
|
||
theme_count = _name_flat_themes(extract_dir, theme_name)
|
||
master_count = (
|
||
apply_master_text_style_spec(extract_dir, master_text_style_spec)
|
||
if master_text_style_spec is not None
|
||
else 0
|
||
)
|
||
|
||
for part_path, expected_name, expected_layout in (
|
||
(master_path, master_name, False),
|
||
(layout_path, layout_name, True),
|
||
):
|
||
root = ET.parse(part_path).getroot()
|
||
common_slide = root.find(f"{{{PML_NS}}}cSld")
|
||
if common_slide is None or common_slide.get("name") != expected_name:
|
||
raise RuntimeError(
|
||
f"Flat structure identity read-back failed: {part_path}"
|
||
)
|
||
shape_tree = common_slide.find(f"{{{PML_NS}}}spTree")
|
||
if shape_tree is None:
|
||
raise RuntimeError(f"Flat structure shell has no shape tree: {part_path}")
|
||
actual_placeholder_types = tuple(
|
||
sorted(
|
||
_flat_placeholder_type(child) or ""
|
||
for child in shape_tree
|
||
if child.tag in _TOP_LEVEL_SHAPE_TAGS
|
||
)
|
||
)
|
||
expected_placeholder_types = tuple(
|
||
sorted(_FLAT_SYSTEM_PLACEHOLDER_TYPES)
|
||
)
|
||
if actual_placeholder_types != expected_placeholder_types:
|
||
raise RuntimeError(
|
||
"Flat structure shell must retain only one each of the date, "
|
||
f"footer, and slide-number hooks: {part_path}"
|
||
)
|
||
if expected_layout and root.get("type") != "blank":
|
||
raise RuntimeError(f"Flat layout is not typed as Blank: {part_path}")
|
||
|
||
if verbose:
|
||
print(
|
||
" Flat structure: project-owned Master + Blank Layout "
|
||
f"({pruned} stock layout(s), "
|
||
f"{removed_master_shapes + removed_layout_shapes} stock content "
|
||
"shape(s) removed, "
|
||
f"{len(retained_master_placeholders) + len(retained_layout_placeholders)} "
|
||
"system footer hook(s) retained)"
|
||
)
|
||
text_style_status = (
|
||
f"{master_count} master text style(s)"
|
||
if master_text_style_spec is not None
|
||
else "stock text defaults retained (no theme contract)"
|
||
)
|
||
print(f" Flat theme: {theme_count} theme part(s), {text_style_status}")
|
||
|
||
|
||
def _append_relationship(
|
||
rels_path: Path,
|
||
rel_type: str,
|
||
target: str,
|
||
) -> str:
|
||
"""Append a relationship entry with the next available rId."""
|
||
with open(rels_path, 'r', encoding='utf-8') as f:
|
||
rels_content = f.read()
|
||
|
||
rid_numbers = [int(match) for match in re.findall(r'Id="rId(\d+)"', rels_content)]
|
||
next_rid = f'rId{max(rid_numbers, default=0) + 1}'
|
||
rel_xml = (
|
||
f' <Relationship Id="{next_rid}" '
|
||
f'Type="{rel_type}" Target="{target}"/>'
|
||
)
|
||
rels_content = rels_content.replace(
|
||
'</Relationships>', rel_xml + '\n</Relationships>',
|
||
)
|
||
|
||
with open(rels_path, 'w', encoding='utf-8') as f:
|
||
f.write(rels_content)
|
||
|
||
return next_rid
|
||
|
||
|
||
def _add_default_content_type(content_types: str, extension: str, content_type: str) -> str:
|
||
"""Add a Default content type if it is not already present."""
|
||
ext = extension.lstrip(".")
|
||
if f'Extension="{ext}"' in content_types:
|
||
return content_types
|
||
entry = f' <Default Extension="{ext}" ContentType="{content_type}"/>'
|
||
override_pos = content_types.find('<Override ')
|
||
if override_pos >= 0:
|
||
return content_types[:override_pos] + entry + '\n' + content_types[override_pos:]
|
||
return content_types.replace('</Types>', entry + '\n</Types>')
|
||
|
||
|
||
def _add_content_type_override(content_types: str, part_name: str, content_type: str) -> str:
|
||
"""Add an Override content type if it is not already present."""
|
||
normalized = '/' + part_name.lstrip('/')
|
||
if f'PartName="{normalized}"' in content_types:
|
||
return content_types
|
||
entry = f' <Override PartName="{normalized}" ContentType="{content_type}"/>'
|
||
return content_types.replace('</Types>', entry + '\n</Types>')
|
||
|
||
|
||
_IMAGE_CONTENT_TYPES = {
|
||
'png': 'image/png',
|
||
'jpg': 'image/jpeg',
|
||
'jpeg': 'image/jpeg',
|
||
'gif': 'image/gif',
|
||
'webp': 'image/webp',
|
||
'svg': 'image/svg+xml',
|
||
'bmp': 'image/bmp',
|
||
'emf': 'image/x-emf',
|
||
'tif': 'image/tiff',
|
||
'tiff': 'image/tiff',
|
||
'wmf': 'image/x-wmf',
|
||
}
|
||
|
||
|
||
def _content_type_for_extension(ext: str) -> str:
|
||
clean = ext.lower().lstrip('.')
|
||
content_type = _IMAGE_CONTENT_TYPES.get(clean) or mimetypes.guess_type(f'x.{clean}')[0]
|
||
if not content_type:
|
||
raise ValueError(f"Unknown media content type for extension: {ext}")
|
||
return content_type
|
||
|
||
|
||
def _as_dict(value: Any) -> dict[str, Any]:
|
||
return value if isinstance(value, dict) else {}
|
||
|
||
|
||
def _create_writable_work_dir(output_path: Path) -> Path:
|
||
"""Create a real writable work directory for PPTX assembly."""
|
||
parents = [output_path.parent, Path.cwd(), Path(tempfile.gettempdir())]
|
||
seen: set[str] = set()
|
||
errors: list[str] = []
|
||
|
||
for parent in parents:
|
||
parent = parent if str(parent) else Path(".")
|
||
try:
|
||
key = str(parent.resolve())
|
||
except OSError:
|
||
key = str(parent.absolute())
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
|
||
try:
|
||
parent.mkdir(parents=True, exist_ok=True)
|
||
except OSError as exc:
|
||
errors.append(f"{parent}: cannot create parent ({exc})")
|
||
continue
|
||
|
||
for _ in range(3):
|
||
work_dir = parent / f".pptx-build-{os.getpid()}-{uuid.uuid4().hex}"
|
||
try:
|
||
work_dir.mkdir(mode=0o700)
|
||
probe_path = work_dir / ".write-probe"
|
||
probe_path.write_text("ok", encoding="utf-8")
|
||
probe_path.unlink()
|
||
return work_dir
|
||
except OSError as exc:
|
||
errors.append(f"{work_dir}: {exc}")
|
||
shutil.rmtree(work_dir, ignore_errors=True)
|
||
|
||
details = "\n - ".join(errors) if errors else "no candidate directories available"
|
||
raise PermissionError(
|
||
"Unable to create a writable PPTX work directory. "
|
||
"Set the output path to a writable project directory or adjust sandbox permissions. "
|
||
f"Tried:\n - {details}"
|
||
)
|
||
|
||
|
||
def _relax_output_permissions(output_path: Path) -> list[str]:
|
||
"""Make exported files readable outside the sandbox owner where possible."""
|
||
warnings: list[str] = []
|
||
|
||
try:
|
||
current_mode = output_path.stat().st_mode
|
||
readable_mode = (
|
||
current_mode
|
||
| stat.S_IRUSR
|
||
| stat.S_IWUSR
|
||
| stat.S_IRGRP
|
||
| stat.S_IROTH
|
||
)
|
||
os.chmod(output_path, readable_mode)
|
||
except OSError as exc:
|
||
warnings.append(f"chmod skipped for {output_path}: {exc}")
|
||
|
||
if os.name != 'nt':
|
||
return warnings
|
||
|
||
# Windows ACLs can remain sandbox-only even when the file mode looks sane.
|
||
# Grant the built-in Users SID read access; the SID avoids localization
|
||
# issues on non-English Windows installations.
|
||
try:
|
||
result = subprocess.run(
|
||
['icacls', str(output_path), '/grant', '*S-1-5-32-545:R'],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
except OSError as exc:
|
||
warnings.append(f"icacls skipped for {output_path}: {exc}")
|
||
else:
|
||
if result.returncode != 0:
|
||
message = (result.stderr or result.stdout or '').strip()
|
||
details = f": {message}" if message else ''
|
||
warnings.append(f"icacls failed for {output_path}{details}")
|
||
|
||
return warnings
|
||
|
||
|
||
_NOTES_MASTER_REL_TYPE = (
|
||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster'
|
||
)
|
||
|
||
|
||
def _ensure_notes_master(
|
||
extract_dir: Path,
|
||
primary_language: str | None = None,
|
||
) -> None:
|
||
"""Create notesMaster parts and wire them into the presentation package."""
|
||
ppt_dir = extract_dir / 'ppt'
|
||
notes_masters_dir = ppt_dir / 'notesMasters'
|
||
notes_masters_dir.mkdir(exist_ok=True)
|
||
|
||
notes_master_path = notes_masters_dir / 'notesMaster1.xml'
|
||
if not notes_master_path.exists():
|
||
notes_master_path.write_text(
|
||
create_notes_master_xml(primary_language),
|
||
encoding='utf-8',
|
||
)
|
||
|
||
theme_dir = ppt_dir / 'theme'
|
||
theme_dir.mkdir(exist_ok=True)
|
||
theme1_path = theme_dir / 'theme1.xml'
|
||
theme2_path = theme_dir / 'theme2.xml'
|
||
if not theme2_path.exists():
|
||
if theme1_path.exists():
|
||
shutil.copy2(theme1_path, theme2_path)
|
||
else:
|
||
raise RuntimeError('Cannot create notes theme: ppt/theme/theme1.xml is missing')
|
||
|
||
notes_master_rels_dir = notes_masters_dir / '_rels'
|
||
notes_master_rels_dir.mkdir(exist_ok=True)
|
||
notes_master_rels_path = notes_master_rels_dir / 'notesMaster1.xml.rels'
|
||
if not notes_master_rels_path.exists():
|
||
notes_master_rels_path.write_text(
|
||
create_notes_master_rels_xml(),
|
||
encoding='utf-8',
|
||
)
|
||
|
||
presentation_rels_path = ppt_dir / '_rels' / 'presentation.xml.rels'
|
||
notes_master_rid = _find_relationship_id(
|
||
presentation_rels_path,
|
||
_NOTES_MASTER_REL_TYPE,
|
||
'notesMasters/notesMaster1.xml',
|
||
)
|
||
if notes_master_rid is None:
|
||
notes_master_rid = _append_relationship(
|
||
presentation_rels_path,
|
||
_NOTES_MASTER_REL_TYPE,
|
||
'notesMasters/notesMaster1.xml',
|
||
)
|
||
|
||
presentation_path = ppt_dir / 'presentation.xml'
|
||
presentation_xml = presentation_path.read_text(encoding='utf-8')
|
||
if '<p:notesMasterIdLst>' in presentation_xml:
|
||
return
|
||
notes_master_lst = (
|
||
f'<p:notesMasterIdLst><p:notesMasterId r:id="{notes_master_rid}"/>'
|
||
'</p:notesMasterIdLst>'
|
||
)
|
||
if '</p:sldMasterIdLst>' not in presentation_xml:
|
||
raise RuntimeError('presentation.xml is missing p:sldMasterIdLst')
|
||
presentation_xml = presentation_xml.replace(
|
||
'</p:sldMasterIdLst>',
|
||
'</p:sldMasterIdLst>' + notes_master_lst,
|
||
1,
|
||
)
|
||
presentation_path.write_text(presentation_xml, encoding='utf-8')
|
||
|
||
|
||
def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dict[str, Any]:
|
||
if not animation_config:
|
||
return {}
|
||
slides_value = animation_config.get('slides', {})
|
||
if not isinstance(slides_value, dict):
|
||
raise ValueError('animations.json field "slides" must be an object')
|
||
slide_value = slides_value.get(svg_stem, {})
|
||
if not isinstance(slide_value, dict):
|
||
raise ValueError(
|
||
f'animations.json slide "{svg_stem}" must be an object'
|
||
)
|
||
return slide_value
|
||
|
||
|
||
def _slide_transition_settings(
|
||
default_transition_cfg: dict[str, Any],
|
||
slide_cfg: dict[str, Any],
|
||
transition: str | None,
|
||
transition_effect_options: dict[str, object] | None,
|
||
duration: float,
|
||
auto_advance: float | None,
|
||
transition_sound: str | None,
|
||
cli_overrides: dict[str, bool],
|
||
) -> tuple[str | None, dict[str, object], float, float | None, str | None]:
|
||
trans_value = slide_cfg.get('transition', {})
|
||
if not isinstance(trans_value, dict):
|
||
raise ValueError('animations.json slide transition must be an object')
|
||
trans_cfg = trans_value
|
||
effect, effect_options = normalize_transition_effect_request(
|
||
transition,
|
||
transition_effect_options,
|
||
)
|
||
if not cli_overrides.get('transition'):
|
||
if 'effect' in trans_cfg:
|
||
raw_effect = trans_cfg['effect']
|
||
raw_options = trans_cfg.get('effect_options')
|
||
effect, effect_options = normalize_transition_effect_request(
|
||
raw_effect,
|
||
raw_options,
|
||
)
|
||
elif 'effect_options' in trans_cfg:
|
||
raise ValueError(
|
||
'animations.json transition effect_options requires '
|
||
'an explicit effect'
|
||
)
|
||
if not cli_overrides.get('transition_duration'):
|
||
if 'duration' in trans_cfg:
|
||
duration = validate_seconds(
|
||
trans_cfg.get('duration'),
|
||
"transition duration",
|
||
allow_zero=effect is None,
|
||
)
|
||
if not cli_overrides.get('auto_advance') and 'auto_advance' in trans_cfg:
|
||
auto_advance = validate_seconds(
|
||
trans_cfg.get('auto_advance'),
|
||
"transition auto_advance",
|
||
allow_zero=True,
|
||
)
|
||
raw_sound = transition_sound
|
||
if raw_sound is None and not cli_overrides.get('transition_sound'):
|
||
raw_sound = default_transition_cfg.get('sound')
|
||
if 'sound' in trans_cfg:
|
||
raw_sound = trans_cfg['sound']
|
||
if raw_sound is not None and (
|
||
not isinstance(raw_sound, str) or not raw_sound.strip()
|
||
):
|
||
raise ValueError(
|
||
'animations.json transition sound must be a non-empty '
|
||
'project-relative .wav path or null'
|
||
)
|
||
return effect, effect_options, duration, auto_advance, raw_sound
|
||
|
||
|
||
def _slide_animation_settings(
|
||
slide_cfg: dict[str, Any],
|
||
default_animation_cfg: dict[str, Any],
|
||
animation: str | None,
|
||
duration: float,
|
||
stagger: float,
|
||
trigger: str,
|
||
cli_overrides: dict[str, bool],
|
||
) -> tuple[str | None, float, float, str, dict[str, Any]]:
|
||
anim_value = slide_cfg.get('animation', {})
|
||
if not isinstance(anim_value, dict):
|
||
raise ValueError('animations.json slide animation must be an object')
|
||
anim_cfg = anim_value
|
||
resolved_cfg = resolve_slide_animation_config(
|
||
default_animation_cfg,
|
||
anim_cfg,
|
||
)
|
||
if cli_overrides.get('animation'):
|
||
effect, effect_options = normalize_animation_effect_request(
|
||
animation,
|
||
allow_none=True,
|
||
allow_modes=True,
|
||
)
|
||
resolved_cfg['effect'] = effect or 'none'
|
||
if effect_options:
|
||
resolved_cfg['effect_options'] = effect_options
|
||
else:
|
||
resolved_cfg.pop('effect_options', None)
|
||
else:
|
||
raw_effect = resolved_cfg.get('effect', animation)
|
||
effect, effect_options = normalize_animation_effect_request(
|
||
raw_effect,
|
||
resolved_cfg.get('effect_options'),
|
||
allow_none=True,
|
||
allow_modes=True,
|
||
)
|
||
resolved_cfg['effect'] = effect or 'none'
|
||
if effect_options:
|
||
resolved_cfg['effect_options'] = effect_options
|
||
else:
|
||
resolved_cfg.pop('effect_options', None)
|
||
if not cli_overrides.get('animation_duration'):
|
||
duration = validate_seconds(
|
||
anim_cfg.get('duration', duration),
|
||
'animation duration',
|
||
allow_zero=False,
|
||
)
|
||
else:
|
||
resolved_cfg['duration'] = duration
|
||
if not cli_overrides.get('animation_stagger'):
|
||
stagger = validate_seconds(
|
||
anim_cfg.get('stagger', stagger),
|
||
'animation stagger',
|
||
allow_zero=True,
|
||
)
|
||
else:
|
||
resolved_cfg['stagger'] = stagger
|
||
if not cli_overrides.get('animation_trigger') and 'trigger' in anim_cfg:
|
||
trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
|
||
else:
|
||
trigger = normalize_animation_trigger(trigger)
|
||
resolved_cfg['trigger'] = trigger
|
||
animation_seconds_to_milliseconds(
|
||
duration,
|
||
'animation duration',
|
||
allow_zero=False,
|
||
)
|
||
animation_seconds_to_milliseconds(
|
||
stagger,
|
||
'animation stagger',
|
||
allow_zero=True,
|
||
)
|
||
resolved_cfg['effect'] = effect or 'none'
|
||
resolved_cfg['duration'] = duration
|
||
resolved_cfg['stagger'] = stagger
|
||
resolved_cfg['trigger'] = trigger
|
||
return effect, duration, stagger, trigger, resolved_cfg
|
||
|
||
|
||
def _build_sequence_targets(
|
||
anim_targets: list[tuple[int, str]],
|
||
slide_name: str,
|
||
slide_cfg: dict[str, Any],
|
||
animation: str | None,
|
||
animation_cfg: dict[str, Any],
|
||
duration: float,
|
||
stagger: float,
|
||
mixed_animation_offset: int,
|
||
animation_rng: random.Random,
|
||
) -> tuple[list[dict[str, Any]], int]:
|
||
groups_value = slide_cfg.get('groups', {})
|
||
if not isinstance(groups_value, dict):
|
||
raise ValueError('animations.json slide groups must be an object')
|
||
groups_cfg = groups_value
|
||
shape_ids_by_group = {
|
||
svg_id: sid for sid, svg_id in anim_targets
|
||
}
|
||
ordered: list[tuple[int, int, int, str, str, dict[str, Any]]] = []
|
||
for idx, (sid, svg_id) in enumerate(anim_targets):
|
||
group_value = groups_cfg.get(svg_id, {})
|
||
if not isinstance(group_value, dict):
|
||
raise ValueError(
|
||
f'animations.json group "{svg_id}" must be an object'
|
||
)
|
||
group_path = (
|
||
f'slides[{json.dumps(slide_name, ensure_ascii=False)}]'
|
||
f'.groups[{json.dumps(svg_id, ensure_ascii=False)}]'
|
||
)
|
||
effect_entries = animation_group_effect_entries(
|
||
group_value,
|
||
path=group_path,
|
||
)
|
||
for effect_idx, (effect_path, effect_cfg) in enumerate(effect_entries):
|
||
raw_effect = effect_cfg.get('effect')
|
||
if raw_effect is not None:
|
||
normalized_effect = normalize_animation_effect(
|
||
raw_effect,
|
||
allow_none=True,
|
||
allow_modes=True,
|
||
)
|
||
else:
|
||
normalized_effect = None
|
||
if 'effect' in effect_cfg and normalized_effect is None:
|
||
continue
|
||
if animation is None and normalized_effect is None:
|
||
continue
|
||
order_value = effect_cfg.get('order')
|
||
order = order_value if order_value is not None else idx + 1
|
||
if (
|
||
isinstance(order, bool)
|
||
or not isinstance(order, int)
|
||
or order <= 0
|
||
):
|
||
raise ValueError(
|
||
f'animations.json {effect_path}.order must be '
|
||
'a positive integer'
|
||
)
|
||
effect_entry = dict(effect_cfg)
|
||
effect_entry['_shape_id'] = sid
|
||
effect_entry['_effect'] = normalized_effect
|
||
effect_entry['_effect_raw'] = raw_effect
|
||
ordered.append(
|
||
(
|
||
order,
|
||
idx,
|
||
effect_idx,
|
||
svg_id,
|
||
effect_path,
|
||
effect_entry,
|
||
)
|
||
)
|
||
|
||
ordered.sort(key=lambda item: (item[0], item[1], item[2]))
|
||
|
||
seq_targets: list[dict[str, Any]] = []
|
||
resolved_group_modes: list[str | None] = []
|
||
main_sequence_count = 0
|
||
for seq_idx, (
|
||
_order,
|
||
_original_idx,
|
||
_effect_idx,
|
||
_svg_id,
|
||
effect_path,
|
||
group_cfg,
|
||
) in enumerate(ordered):
|
||
shape_id = int(group_cfg['_shape_id'])
|
||
raw_effect = group_cfg.get('_effect')
|
||
resolved_group_modes.append(
|
||
raw_effect if raw_effect in ('auto', 'mixed', 'random') else None
|
||
)
|
||
if raw_effect in ('auto', 'mixed', 'random'):
|
||
effect = pick_animation_effect(
|
||
str(raw_effect), seq_idx, mixed_animation_offset, group_id=_svg_id,
|
||
rng=animation_rng,
|
||
)
|
||
effect_options: dict[str, object] = {}
|
||
else:
|
||
effect = str(raw_effect or pick_animation_effect(
|
||
animation, seq_idx, mixed_animation_offset, group_id=_svg_id,
|
||
rng=animation_rng,
|
||
))
|
||
request_effect = (
|
||
group_cfg.get('_effect_raw')
|
||
if group_cfg.get('_effect_raw') is not None
|
||
else effect
|
||
)
|
||
option_value = (
|
||
group_cfg.get('effect_options')
|
||
if group_cfg.get('_effect_raw') is not None
|
||
else animation_cfg.get('effect_options')
|
||
)
|
||
effect, effect_options = normalize_animation_effect_request(
|
||
request_effect,
|
||
option_value,
|
||
allow_none=False,
|
||
allow_modes=False,
|
||
)
|
||
item_duration = validate_seconds(
|
||
group_cfg.get('duration', duration),
|
||
f'animations.json {effect_path}.duration',
|
||
allow_zero=False,
|
||
)
|
||
trigger_shape = group_cfg.get('trigger_shape')
|
||
raw_trigger = group_cfg.get(
|
||
'trigger',
|
||
animation_cfg.get('trigger', 'after-previous'),
|
||
)
|
||
resolved_trigger = normalize_animation_trigger(raw_trigger)
|
||
if trigger_shape is not None:
|
||
if 'trigger' in group_cfg and resolved_trigger != 'on-click':
|
||
raise ValueError(
|
||
f'animations.json {effect_path}.trigger_shape requires '
|
||
'trigger "on-click" when trigger is explicit'
|
||
)
|
||
resolved_trigger = 'on-click'
|
||
default_delay = (
|
||
stagger
|
||
if (
|
||
trigger_shape is None
|
||
and resolved_trigger == 'after-previous'
|
||
and main_sequence_count > 0
|
||
)
|
||
else 0
|
||
)
|
||
delay_seconds = validate_seconds(
|
||
group_cfg.get('delay', default_delay),
|
||
f'animations.json {effect_path}.delay',
|
||
allow_zero=True,
|
||
)
|
||
delay_ms = animation_seconds_to_milliseconds(
|
||
delay_seconds,
|
||
f'animations.json {effect_path}.delay',
|
||
allow_zero=True,
|
||
)
|
||
inherited_fields = {
|
||
field: animation_cfg[field]
|
||
for field in (
|
||
*ANIMATION_TIMING_OPTION_FIELDS,
|
||
'after_effect',
|
||
'sound',
|
||
)
|
||
if field in animation_cfg
|
||
}
|
||
inherited_fields.update(
|
||
{
|
||
field: group_cfg[field]
|
||
for field in (
|
||
*ANIMATION_TIMING_OPTION_FIELDS,
|
||
'after_effect',
|
||
'sound',
|
||
)
|
||
if field in group_cfg
|
||
}
|
||
)
|
||
target_entry: dict[str, Any] = {
|
||
'shape_id': shape_id,
|
||
'delay_ms': delay_ms,
|
||
'effect': effect,
|
||
'effect_options': effect_options,
|
||
'duration': item_duration,
|
||
'trigger': resolved_trigger,
|
||
}
|
||
if trigger_shape is not None:
|
||
if not isinstance(trigger_shape, str) or not trigger_shape.strip():
|
||
raise ValueError(
|
||
f'animations.json {effect_path}.trigger_shape must '
|
||
'be a non-empty group id'
|
||
)
|
||
trigger_shape_id = shape_ids_by_group.get(trigger_shape)
|
||
if trigger_shape_id is None:
|
||
raise ValueError(
|
||
f'animations.json {effect_path}.trigger_shape '
|
||
f'references a missing or non-triggerable group: '
|
||
f'{trigger_shape}'
|
||
)
|
||
if trigger_shape_id == shape_id:
|
||
raise ValueError(
|
||
f'animations.json {effect_path}.trigger_shape must '
|
||
'reference a different group'
|
||
)
|
||
target_entry['trigger_shape_id'] = trigger_shape_id
|
||
else:
|
||
main_sequence_count += 1
|
||
target_entry.update(inherited_fields)
|
||
if 'sound' in target_entry:
|
||
target_entry['_sound_path'] = target_entry.pop('sound')
|
||
seq_targets.append(target_entry)
|
||
|
||
mixed_count = 0
|
||
if animation == 'mixed':
|
||
mixed_count = sum(1 for _target in seq_targets[1:])
|
||
elif animation == 'auto':
|
||
# 'auto' accumulates a cross-slide offset so the image pool and the
|
||
# unmatched-id fallback rotate as the deck advances. Single-effect
|
||
# semantic matches (title→entrance_fade, chart→entrance_wipe, etc.)
|
||
# are unaffected
|
||
# because they ignore the offset.
|
||
mixed_count = len(seq_targets)
|
||
else:
|
||
mixed_count = sum(
|
||
1
|
||
for seq_idx, mode in enumerate(resolved_group_modes)
|
||
if mode == 'auto' or (mode == 'mixed' and seq_idx > 0)
|
||
)
|
||
return seq_targets, mixed_count
|
||
|
||
|
||
def _next_relationship_id(rel_entries: list[dict[str, str]]) -> str:
|
||
"""Return the next slide relationship id, keeping rId1 for the layout."""
|
||
used = {1}
|
||
for rel in rel_entries:
|
||
match = re.fullmatch(r'rId(\d+)', str(rel.get('id', '')))
|
||
if match:
|
||
used.add(int(match.group(1)))
|
||
candidate = 2
|
||
while candidate in used:
|
||
candidate += 1
|
||
return f'rId{candidate}'
|
||
|
||
|
||
def _materialize_slide_sound(
|
||
project_path: Path,
|
||
raw_sound: str,
|
||
media_files: dict[str, bytes],
|
||
rel_entries: list[dict[str, str]],
|
||
audio_exts_used: set[str],
|
||
packaged_by_source: dict[Path, tuple[str, str]],
|
||
*,
|
||
label: str,
|
||
media_prefix: str,
|
||
require_project_relative_wav: bool,
|
||
) -> dict[str, str]:
|
||
"""Package one slide sound and return its relationship descriptor."""
|
||
if not isinstance(raw_sound, str) or not raw_sound.strip():
|
||
raise ValueError(f'{label} sound must be a non-empty path string')
|
||
sound_path = Path(raw_sound)
|
||
if require_project_relative_wav:
|
||
if sound_path.is_absolute() or PureWindowsPath(raw_sound).drive:
|
||
raise ValueError(f'{label} sound must be project-relative: {raw_sound!r}')
|
||
extension = sound_path.suffix.lower()
|
||
if extension != '.wav':
|
||
raise ValueError(f'{label} sound must use .wav')
|
||
project_root = project_path.resolve()
|
||
sound_path = (project_root / sound_path).resolve()
|
||
try:
|
||
sound_path.relative_to(project_root)
|
||
except ValueError as exc:
|
||
raise ValueError(
|
||
f'{label} sound escapes the project root: {raw_sound!r}'
|
||
) from exc
|
||
else:
|
||
if not sound_path.is_absolute():
|
||
sound_path = project_path / sound_path
|
||
sound_path = sound_path.resolve()
|
||
extension = sound_path.suffix.lower()
|
||
|
||
if not sound_path.is_file():
|
||
raise ValueError(f'{label} sound file not found: {sound_path}')
|
||
if extension not in AUDIO_CONTENT_TYPES:
|
||
valid = ', '.join(sorted(AUDIO_CONTENT_TYPES))
|
||
raise ValueError(
|
||
f'unsupported {label} sound format {extension or "(none)"}; '
|
||
f'valid formats: {valid}'
|
||
)
|
||
|
||
packaged = packaged_by_source.get(sound_path)
|
||
if packaged is None:
|
||
payload = sound_path.read_bytes()
|
||
if require_project_relative_wav and not (
|
||
len(payload) >= 12
|
||
and payload[:4] in {b'RIFF', b'RF64'}
|
||
and payload[8:12] == b'WAVE'
|
||
):
|
||
raise ValueError(f'{label} sound is not a valid WAV file: {sound_path}')
|
||
digest = hashlib.sha256(payload).hexdigest()[:16]
|
||
media_name = f'{media_prefix}_{digest}{extension}'
|
||
relationship_id = _next_relationship_id(rel_entries)
|
||
media_files.setdefault(media_name, payload)
|
||
rel_entries.append(
|
||
{
|
||
'id': relationship_id,
|
||
'type': AUDIO_REL_TYPE,
|
||
'target': f'../media/{media_name}',
|
||
}
|
||
)
|
||
packaged = (relationship_id, media_name)
|
||
packaged_by_source[sound_path] = packaged
|
||
audio_exts_used.add(extension)
|
||
|
||
relationship_id, _media_name = packaged
|
||
return {
|
||
'relationship_id': relationship_id,
|
||
'name': sound_path.name,
|
||
}
|
||
|
||
|
||
def _materialize_transition_sound(
|
||
project_path: Path,
|
||
raw_sound: str | None,
|
||
media_files: dict[str, bytes],
|
||
rel_entries: list[dict[str, str]],
|
||
audio_exts_used: set[str],
|
||
packaged_by_source: dict[Path, tuple[str, str]],
|
||
) -> dict[str, str] | None:
|
||
"""Package one optional project-local WAV for a slide transition."""
|
||
if raw_sound is None:
|
||
return None
|
||
return _materialize_slide_sound(
|
||
project_path,
|
||
raw_sound,
|
||
media_files,
|
||
rel_entries,
|
||
audio_exts_used,
|
||
packaged_by_source,
|
||
label='transition',
|
||
media_prefix='transition_sound',
|
||
require_project_relative_wav=True,
|
||
)
|
||
|
||
|
||
def _materialize_animation_sounds(
|
||
project_path: Path,
|
||
targets: list[dict[str, Any]],
|
||
media_files: dict[str, bytes],
|
||
rel_entries: list[dict[str, str]],
|
||
audio_exts_used: set[str],
|
||
packaged_by_source: dict[Path, tuple[str, str]] | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
"""Package sidecar sound files and replace paths with OOXML relationships."""
|
||
materialized: list[dict[str, Any]] = []
|
||
packaged_by_source = packaged_by_source if packaged_by_source is not None else {}
|
||
for index, raw_target in enumerate(targets, 1):
|
||
target = dict(raw_target)
|
||
raw_sound = target.pop('_sound_path', None)
|
||
if raw_sound is None:
|
||
materialized.append(target)
|
||
continue
|
||
target['sound'] = _materialize_slide_sound(
|
||
project_path,
|
||
raw_sound,
|
||
media_files,
|
||
rel_entries,
|
||
audio_exts_used,
|
||
packaged_by_source,
|
||
label=f'animation target {index}',
|
||
media_prefix='animation_sound',
|
||
require_project_relative_wav=False,
|
||
)
|
||
materialized.append(target)
|
||
return materialized
|
||
|
||
|
||
def _prerender_legacy_pngs(
|
||
svg_files: list[Path],
|
||
media_dir: Path,
|
||
pixel_width: int,
|
||
pixel_height: int,
|
||
cache_dir: Path | None,
|
||
workers: int,
|
||
verbose: bool,
|
||
) -> dict[int, bool]:
|
||
"""Render every SVG→PNG into media_dir in parallel.
|
||
|
||
Returns {1-based slide index: success}. Falls back to sequential when
|
||
workers<=1 or len(svg_files)<=2.
|
||
"""
|
||
results: dict[int, bool] = {}
|
||
targets: list[tuple[int, Path, Path]] = [
|
||
(i, svg, media_dir / f'image{i}.png')
|
||
for i, svg in enumerate(svg_files, 1)
|
||
]
|
||
|
||
if workers <= 1 or len(targets) <= 2:
|
||
for i, svg, png in targets:
|
||
ok = convert_svg_to_png_cached(svg, png, pixel_width, pixel_height, cache_dir)
|
||
results[i] = ok
|
||
if verbose:
|
||
tag = 'cached/ok' if ok else 'failed'
|
||
print(f" [PNG {i}/{len(targets)}] {svg.name} - {tag}")
|
||
return results
|
||
|
||
with ProcessPoolExecutor(max_workers=workers) as pool:
|
||
future_map = {
|
||
pool.submit(
|
||
convert_svg_to_png_cached,
|
||
svg, png, pixel_width, pixel_height, cache_dir,
|
||
): (i, svg)
|
||
for i, svg, png in targets
|
||
}
|
||
done = 0
|
||
for future in as_completed(future_map):
|
||
i, svg = future_map[future]
|
||
try:
|
||
ok = future.result()
|
||
except Exception as exc:
|
||
ok = False
|
||
if verbose:
|
||
print(f" [PNG] {svg.name} - worker error: {exc}")
|
||
results[i] = ok
|
||
done += 1
|
||
if verbose:
|
||
tag = 'cached/ok' if ok else 'failed'
|
||
print(f" [PNG {done}/{len(targets)}] {svg.name} - {tag}")
|
||
|
||
return results
|
||
|
||
|
||
def _presentation_format(width: float, height: float) -> str:
|
||
"""Map the slide aspect ratio to PowerPoint's PresentationFormat label.
|
||
Non-standard ratios (square, portrait, banner crops) report 'Custom'.
|
||
"""
|
||
if width <= 0 or height <= 0:
|
||
return 'Custom'
|
||
ratio = width / height
|
||
for target, label in (
|
||
(4 / 3, 'On-screen Show (4:3)'),
|
||
(16 / 9, 'On-screen Show (16:9)'),
|
||
(16 / 10, 'On-screen Show (16:10)'),
|
||
):
|
||
if abs(ratio - target) < 0.02:
|
||
return label
|
||
return 'Custom'
|
||
|
||
|
||
def _stamp_docprops(
|
||
extract_dir: Path,
|
||
slide_count: int,
|
||
pres_format: str,
|
||
meta: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Overwrite the misleading python-pptx default metadata with accurate
|
||
values. Factual fields (slide count, export timestamp, presentation format,
|
||
application) are always machine-derived. Authored fields — including the
|
||
title — come solely from an optional per-project ``metadata.json``
|
||
(``meta``); whatever it omits stays blank. ``lastModifiedBy`` follows
|
||
``creator`` rather than ever carrying the base template's author or a tool
|
||
name. No field is guessed from slide content: a blank title is preferable
|
||
to an unreliable heuristic pick.
|
||
"""
|
||
meta = meta or {}
|
||
|
||
def field(key: str, default: str = '') -> str:
|
||
value = meta.get(key)
|
||
return value.strip() if isinstance(value, str) and value.strip() else default
|
||
|
||
title = field('title')
|
||
creator = field('creator')
|
||
|
||
now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||
|
||
core_path = extract_dir / 'docProps' / 'core.xml'
|
||
if core_path.exists():
|
||
core_path.write_text(
|
||
"<?xml version='1.0' encoding='UTF-8' standalone='yes'?>\n"
|
||
'<cp:coreProperties '
|
||
'xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
|
||
'xmlns:dc="http://purl.org/dc/elements/1.1/" '
|
||
'xmlns:dcterms="http://purl.org/dc/terms/" '
|
||
'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
|
||
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
|
||
f'<dc:title>{escape(title)}</dc:title>'
|
||
f'<dc:subject>{escape(field("subject"))}</dc:subject>'
|
||
f'<dc:creator>{escape(creator)}</dc:creator>'
|
||
f'<cp:keywords>{escape(field("keywords"))}</cp:keywords>'
|
||
f'<dc:description>{escape(field("description"))}</dc:description>'
|
||
f'<dc:language>{escape(field("language"))}</dc:language>'
|
||
f'<cp:lastModifiedBy>{escape(creator)}</cp:lastModifiedBy>'
|
||
'<cp:revision>1</cp:revision>'
|
||
f'<dcterms:created xsi:type="dcterms:W3CDTF">{now}</dcterms:created>'
|
||
f'<dcterms:modified xsi:type="dcterms:W3CDTF">{now}</dcterms:modified>'
|
||
f'<cp:category>{escape(field("category"))}</cp:category>'
|
||
f'<cp:contentStatus>{escape(field("contentStatus"))}</cp:contentStatus>'
|
||
'</cp:coreProperties>',
|
||
encoding='utf-8',
|
||
)
|
||
|
||
app_path = extract_dir / 'docProps' / 'app.xml'
|
||
if app_path.exists():
|
||
app = app_path.read_text(encoding='utf-8')
|
||
app = re.sub(r'<Slides>.*?</Slides>', f'<Slides>{slide_count}</Slides>', app)
|
||
app = re.sub(
|
||
r'<Company>.*?</Company>',
|
||
f'<Company>{escape(field("company"))}</Company>',
|
||
app,
|
||
)
|
||
app = re.sub(
|
||
r'<Manager>.*?</Manager>',
|
||
f'<Manager>{escape(field("manager"))}</Manager>',
|
||
app,
|
||
)
|
||
app = re.sub(
|
||
r'<Application>.*?</Application>',
|
||
'<Application>Microsoft Office PowerPoint</Application>',
|
||
app,
|
||
)
|
||
app = re.sub(
|
||
r'<PresentationFormat>.*?</PresentationFormat>',
|
||
f'<PresentationFormat>{escape(pres_format)}</PresentationFormat>',
|
||
app,
|
||
)
|
||
app_path.write_text(app, encoding='utf-8')
|
||
|
||
|
||
def _create_preserved_base_pptx(
|
||
contract: NativeStructureContract,
|
||
specs: list[TemplateSlideSpec],
|
||
output_path: Path,
|
||
slide_size_emu: tuple[int, int],
|
||
) -> None:
|
||
"""Create empty slides bound to the source package's original layouts."""
|
||
presentation = Presentation(str(contract.source_template))
|
||
actual_size = (int(presentation.slide_width), int(presentation.slide_height))
|
||
if actual_size != contract.slide_size_emu:
|
||
raise TemplateStructureError(
|
||
f"{contract.source_template.name} slide size does not match "
|
||
f"{contract.contract_path.name}"
|
||
)
|
||
if actual_size != slide_size_emu:
|
||
raise TemplateStructureError(
|
||
"Generated SVG canvas does not match the preserved source template size"
|
||
)
|
||
|
||
layouts_by_part = {
|
||
str(layout.part.partname).lstrip("/"): layout
|
||
for master in presentation.slide_masters
|
||
for layout in master.slide_layouts
|
||
}
|
||
slide_ids = presentation.slides._sldIdLst
|
||
for slide_id in list(slide_ids):
|
||
presentation.part.drop_rel(slide_id.rId)
|
||
slide_ids.remove(slide_id)
|
||
|
||
for spec in specs:
|
||
layout_contract = contract.layout(spec.layout_key)
|
||
layout = layouts_by_part.get(layout_contract.package_part)
|
||
if layout is None:
|
||
raise TemplateStructureError(
|
||
f"Preserved source package did not load layout part "
|
||
f"{layout_contract.package_part!r}"
|
||
)
|
||
presentation.slides.add_slide(layout)
|
||
presentation.save(str(output_path))
|
||
|
||
|
||
def _clear_preserved_slide_collections(extract_dir: Path) -> None:
|
||
"""Remove source slide-order metadata that cannot apply to generated pages."""
|
||
presentation_path = extract_dir / "ppt" / "presentation.xml"
|
||
tree = ET.parse(presentation_path)
|
||
root = tree.getroot()
|
||
custom_shows = root.find(f"{{{PML_NS}}}custShowLst")
|
||
if custom_shows is not None:
|
||
root.remove(custom_shows)
|
||
for extension_list in root.findall(f".//{{{PML_NS}}}extLst"):
|
||
for extension in list(extension_list):
|
||
if any(
|
||
child.tag.rsplit("}", 1)[-1] == "sectionLst"
|
||
for child in extension.iter()
|
||
if isinstance(child.tag, str)
|
||
):
|
||
extension_list.remove(extension)
|
||
_write_xml_tree(presentation_path, tree)
|
||
|
||
|
||
def create_pptx_with_native_svg(
|
||
svg_files: list[Path],
|
||
output_path: Path,
|
||
canvas_format: str | None = None,
|
||
verbose: bool = True,
|
||
transition: str | None = 'fade',
|
||
transition_duration: float = 0.5,
|
||
auto_advance: float | None = None,
|
||
use_compat_mode: bool = True,
|
||
notes: dict[str, str] | None = None,
|
||
enable_notes: bool = True,
|
||
use_native_shapes: bool = True,
|
||
animation: str | None = None,
|
||
animation_duration: float = 0.4,
|
||
animation_stagger: float = 0.5,
|
||
animation_trigger: str = 'after-previous',
|
||
animation_config: dict[str, Any] | None = None,
|
||
animation_cli_overrides: dict[str, bool] | None = None,
|
||
narration_audio: dict[str, Path] | None = None,
|
||
use_narration_timings: bool = False,
|
||
narration_padding: float = 0.5,
|
||
cache_dir: Path | None = None,
|
||
workers: int | None = None,
|
||
merge_paragraphs: bool | None = None,
|
||
image_optimize: bool = True,
|
||
image_max_dimension: int | None = 2560,
|
||
image_sizing: str = 'cap',
|
||
image_scale: float = 2.0,
|
||
image_quality: int = 85,
|
||
native_objects: bool = False,
|
||
conversion_trace_path: Path | None = None,
|
||
doc_metadata: dict[str, Any] | None = None,
|
||
structure_name: str | None = None,
|
||
pptx_structure: str = "structured",
|
||
use_layout_placeholder_frames: bool = False,
|
||
native_structure_contract: NativeStructureContract | None = None,
|
||
theme_font_spec: ThemeFontSpec | None = None,
|
||
master_text_style_spec: MasterTextStyleSpec | None = None,
|
||
theme_color_spec: ThemeColorSpec | None = None,
|
||
structured_baseline: bool = False,
|
||
baseline_layout_specs: list[TemplateSlideSpec] | None = None,
|
||
layout_definition_files: list[Path] | None = None,
|
||
expected_viewbox: str | None = None,
|
||
animation_resource_root: Path | None = None,
|
||
transition_effect_options: dict[str, object] | None = None,
|
||
transition_sound: str | None = None,
|
||
text_flow: str | None = None,
|
||
primary_language: str | None = None,
|
||
narration_start_floor: float = DEFAULT_NARRATION_START_FLOOR,
|
||
) -> bool:
|
||
"""Create a PPTX file with native DrawingML shapes.
|
||
|
||
Args:
|
||
svg_files: List of SVG files.
|
||
output_path: Output PPTX path.
|
||
layout_definition_files: Optional structured SVG prototypes for Layouts
|
||
that no generated page uses. They are converted on internal carrier
|
||
slides, registered, and removed before the package is published.
|
||
canvas_format: Canvas format key.
|
||
expected_viewbox: Optional project/template-lock canvas contract. Every
|
||
public page and internal Layout definition must match it.
|
||
animation_resource_root: Project root for sidecar sound paths. Object
|
||
animation sounds retain existing absolute-path compatibility;
|
||
transition sounds must remain project-relative WAV files.
|
||
verbose: Whether to output detailed information.
|
||
transition: Transition effect name.
|
||
transition_effect_options: PowerPoint Effect Options for the selected
|
||
native page transition.
|
||
transition_sound: Optional project-relative WAV path used by the
|
||
generated page transition.
|
||
transition_duration: Transition duration in seconds.
|
||
auto_advance: Auto-advance interval in seconds.
|
||
use_compat_mode: Retained for API compatibility; ignored in native mode.
|
||
notes: Notes dict, key is SVG stem, value is notes content.
|
||
enable_notes: Whether to enable notes embedding.
|
||
use_native_shapes: Must remain true; SVG-image PPTX export is unsupported.
|
||
animation: Per-element object-animation mode (compatibility alias,
|
||
PowerPoint-native ``entrance_*``/``emphasis_*``/``path_*``/
|
||
``exit_*`` effect, ``'mixed'``, ``'random'``, or None to disable).
|
||
Native shapes mode only.
|
||
animation_duration: Per-element animation duration in seconds.
|
||
Instantaneous native presets retain their PowerPoint-authored
|
||
duration.
|
||
animation_stagger: Delay between elements in ``after-previous``
|
||
trigger mode (seconds). Ignored otherwise.
|
||
animation_trigger: PowerPoint Start mode — ``'after-previous'`` (default),
|
||
``'on-click'``, or ``'with-previous'``.
|
||
animation_config: Optional sidecar overrides loaded from animations.json.
|
||
animation_cli_overrides: Flags indicating explicit CLI overrides.
|
||
narration_audio: Optional dict mapping SVG stem to narration audio file.
|
||
use_narration_timings: Whether to set slide auto-advance from audio duration.
|
||
narration_padding: Extra seconds added after each narration before advancing.
|
||
narration_start_floor: Minimum seconds from transition start to narration
|
||
start. Any remainder after the transition becomes silent lead-in.
|
||
merge_paragraphs: Legacy compatibility option. True selects reflow;
|
||
False selects split. Do not combine with ``text_flow``.
|
||
text_flow: Positional-tspan policy: preserve authored line breaks in
|
||
one frame, reflow text, or split visual lines into separate frames.
|
||
image_optimize: Whether native export optimizes raster images when needed.
|
||
image_max_dimension: Preferred optimized image dimension cap in pixels.
|
||
image_sizing: ``cap`` preserves unchanged source bytes and limits
|
||
oversized sources; ``display`` sizes from rendered SVG boxes.
|
||
image_scale: Target image pixels per SVG display pixel.
|
||
image_quality: JPEG quality used when opaque rasters are re-encoded.
|
||
native_objects: Replace explicit ``data-pptx-replace-with`` chart/table
|
||
fallback groups with native PowerPoint Chart/Table objects. Default off.
|
||
conversion_trace_path: Optional JSON path for native conversion diagnostics.
|
||
structure_name: Current deck identity used to name a flat Master, Layout,
|
||
and theme.
|
||
pptx_structure: PPTX structure strategy. ``baseline`` promotes safe
|
||
shared native backgrounds and leading chrome to slide masters,
|
||
then extracts semantic page-role layout families and exact
|
||
family-wide structurally marked leading chrome; marker-free legacy
|
||
SVGs retain filename/id fallback;
|
||
``structured`` consumes explicit SVG master/layout/placeholder
|
||
metadata; ``preserve`` reuses an imported source PPTX package;
|
||
``flat`` keeps generated content Slide-local and builds one clean
|
||
project-owned Master/Blank-Layout shell.
|
||
use_layout_placeholder_frames: In structured template-review decks, size
|
||
each Slide placeholder carrier to its reusable Layout bounds instead
|
||
of the tight SVG content frame. Default off for generated decks.
|
||
native_structure_contract: Validated source package contract for
|
||
``preserve`` mode.
|
||
theme_font_spec: Locked project major/minor fonts for flat/structured
|
||
release-theme inheritance. Direct diagnostic flat callers may omit it.
|
||
master_text_style_spec: Required declared title/body anchors for structured
|
||
and release flat slide-master text styles. Direct diagnostic flat
|
||
callers may omit it; other routes ignore this value.
|
||
theme_color_spec: Locked project color scheme for context-aware
|
||
flat/structured theme inheritance. Preserve mode ignores this value.
|
||
primary_language: Canonical BCP-47 deck content language. ``None``
|
||
preserves legacy per-run language detection.
|
||
structured_baseline: Obsolete compatibility argument; must remain false.
|
||
baseline_layout_specs: Obsolete compatibility argument; must remain None.
|
||
|
||
Returns:
|
||
Whether all slides were successfully created.
|
||
"""
|
||
text_flow = resolve_text_flow(text_flow, merge_paragraphs)
|
||
if primary_language is not None:
|
||
primary_language = normalize_language_tag(primary_language)
|
||
public_svg_files = list(svg_files)
|
||
definition_svg_files = list(layout_definition_files or [])
|
||
public_slide_names = [path.stem for path in public_svg_files]
|
||
morph_pairs = resolve_morph_pairs(
|
||
public_slide_names,
|
||
animation_config,
|
||
)
|
||
public_slide_numbers = {
|
||
slide_name: slide_number
|
||
for slide_number, slide_name in enumerate(public_slide_names, 1)
|
||
}
|
||
morph_expectations = tuple(
|
||
MorphPairExpectation(
|
||
source_slide_number=public_slide_numbers[pair.source_slide],
|
||
destination_slide_number=public_slide_numbers[
|
||
pair.destination_slide
|
||
],
|
||
key=pair.key,
|
||
)
|
||
for pair in morph_pairs
|
||
)
|
||
morph_pairs_by_destination: dict[str, list[MorphPair]] = {}
|
||
morph_group_overrides_by_slide: dict[str, set[str]] = {}
|
||
for pair in morph_pairs:
|
||
morph_pairs_by_destination.setdefault(
|
||
pair.destination_slide,
|
||
[],
|
||
).append(pair)
|
||
morph_group_overrides_by_slide.setdefault(
|
||
pair.source_slide,
|
||
set(),
|
||
).add(pair.source_group_id)
|
||
morph_group_overrides_by_slide.setdefault(
|
||
pair.destination_slide,
|
||
set(),
|
||
).add(pair.destination_group_id)
|
||
morph_shape_ids: dict[tuple[str, str], int] = {}
|
||
if definition_svg_files and pptx_structure != "structured":
|
||
raise ValueError(
|
||
"layout_definition_files requires pptx_structure='structured'"
|
||
)
|
||
public_paths = {path.resolve() for path in public_svg_files}
|
||
seen_definition_paths: set[Path] = set()
|
||
for path in definition_svg_files:
|
||
resolved = path.resolve()
|
||
if not path.is_file():
|
||
raise ValueError(f"Layout definition SVG does not exist: {path}")
|
||
if resolved in public_paths:
|
||
raise ValueError(
|
||
f"Layout definition SVG is already a generated page: {path}"
|
||
)
|
||
if resolved in seen_definition_paths:
|
||
raise ValueError(f"Layout definition SVG is repeated: {path}")
|
||
seen_definition_paths.add(resolved)
|
||
public_slide_count = len(public_svg_files)
|
||
svg_files = public_svg_files + definition_svg_files
|
||
total_slide_count = len(svg_files)
|
||
|
||
if not use_native_shapes:
|
||
raise ValueError(
|
||
"SVG-image PPTX export is no longer supported; use svg_final/ "
|
||
"directly for preview and native DrawingML PPTX for delivery"
|
||
)
|
||
if not public_svg_files:
|
||
print("Error: No SVG files found")
|
||
return False
|
||
|
||
use_compat_mode = False
|
||
if pptx_structure not in {"baseline", "structured", "preserve", "flat"}:
|
||
raise ValueError(f"Unsupported pptx_structure: {pptx_structure}")
|
||
requested_canvas_format = canvas_format
|
||
canvas, detected_canvas_format = resolve_svg_canvas(
|
||
svg_files,
|
||
canvas_format=canvas_format,
|
||
expected_viewbox=expected_viewbox,
|
||
)
|
||
if canvas_format is None:
|
||
canvas_format = detected_canvas_format
|
||
if pptx_structure == "flat":
|
||
flat_errors = flat_structure_metadata_errors(public_svg_files)
|
||
if flat_errors:
|
||
details = "\n".join(f" - {error}" for error in flat_errors)
|
||
raise TemplateStructureError(
|
||
"Flat PPTX structure validation failed:\n" + details
|
||
)
|
||
if use_layout_placeholder_frames and pptx_structure != "structured":
|
||
raise ValueError(
|
||
"use_layout_placeholder_frames requires pptx_structure='structured'"
|
||
)
|
||
if structured_baseline:
|
||
raise ValueError(
|
||
"structured_baseline is obsolete; use pptx_structure='structured'"
|
||
)
|
||
if baseline_layout_specs is not None:
|
||
raise ValueError(
|
||
"baseline_layout_specs is obsolete; structured export parses SVG metadata"
|
||
)
|
||
if pptx_structure == "structured" and master_text_style_spec is None:
|
||
raise ValueError(
|
||
"Structured export requires declared typography title/body anchors "
|
||
"in master_text_style_spec"
|
||
)
|
||
if use_native_shapes and pptx_structure == "structured":
|
||
template_specs = parse_template_slides(svg_files)
|
||
public_template_specs = template_specs[:public_slide_count]
|
||
elif use_native_shapes and pptx_structure == "preserve":
|
||
if native_structure_contract is None:
|
||
raise TemplateStructureError(
|
||
"Preserve export requires a validated native structure contract"
|
||
)
|
||
template_specs = parse_preserve_slides(svg_files)
|
||
for spec in template_specs:
|
||
native_structure_contract.layout(spec.layout_key)
|
||
public_template_specs = template_specs
|
||
else:
|
||
template_specs = None
|
||
public_template_specs = None
|
||
template_background_expectations: dict[str, str | None] | None = None
|
||
template_shape_roster_expectations: (
|
||
dict[str, tuple[str, ...]] | None
|
||
) = None
|
||
template_layout_parts_by_key: dict[str, str] | None = None
|
||
template_master_parts_by_key: dict[str, str] | None = None
|
||
if template_specs is not None and not native_objects:
|
||
native_placeholders = sorted({
|
||
item.placeholder
|
||
for spec in template_specs
|
||
for item in spec.placeholders
|
||
if item.placeholder in {"chart", "table"}
|
||
})
|
||
if native_placeholders:
|
||
kinds = ", ".join(str(kind) for kind in native_placeholders)
|
||
context = (
|
||
pptx_structure.capitalize()
|
||
)
|
||
raise TemplateStructureError(
|
||
f"{context} {kinds} placeholder(s) require "
|
||
"--native-charts-and-tables so each marker becomes one native "
|
||
"PowerPoint Chart/Table object"
|
||
)
|
||
|
||
# Check compatibility mode dependencies
|
||
renderer_name, renderer_status, renderer_hint = get_png_renderer_info()
|
||
if not use_native_shapes and use_compat_mode and PNG_RENDERER is None:
|
||
print("Warning: No PNG rendering library installed, cannot use compatibility mode")
|
||
print(f" {renderer_hint}")
|
||
print(" Will use pure SVG mode (may not display in Office LTSC 2021 and similar versions)")
|
||
use_compat_mode = False
|
||
|
||
width_emu, height_emu = canvas.emu_dimensions
|
||
pixel_width, pixel_height = canvas.pixel_dimensions
|
||
pixel_width_label, pixel_height_label = canvas.canonical.split()[2:]
|
||
if verbose and requested_canvas_format is None:
|
||
if canvas_format:
|
||
format_name = CANVAS_FORMATS.get(canvas_format, {}).get('name', canvas_format)
|
||
print(f" Detected canvas format: {format_name}")
|
||
else:
|
||
print(
|
||
" Using SVG viewBox dimensions: "
|
||
f"{canvas.canonical.removeprefix('0 0 ')} px"
|
||
)
|
||
|
||
if verbose:
|
||
print(
|
||
f" Slide dimensions: {pixel_width_label} x "
|
||
f"{pixel_height_label} px"
|
||
)
|
||
print(f" SVG file count: {public_slide_count}")
|
||
if definition_svg_files:
|
||
print(
|
||
" Unused Layout definitions: "
|
||
f"{len(definition_svg_files)} internal prototype(s)"
|
||
)
|
||
if use_native_shapes:
|
||
print(f" Mode: Native DrawingML shapes (directly editable)")
|
||
print(
|
||
" Native table/chart objects: "
|
||
f"{'Enabled' if native_objects else 'Disabled'}"
|
||
)
|
||
print(f" PPTX structure: {pptx_structure}")
|
||
if image_optimize:
|
||
if image_sizing == 'display':
|
||
image_mode = (
|
||
f"display scale {image_scale:g}, "
|
||
f"preferred max {image_max_dimension or 'unlimited'} px"
|
||
)
|
||
else:
|
||
image_mode = (
|
||
f"preferred cap {image_max_dimension or 'unlimited'} px, "
|
||
"unchanged bytes preserved"
|
||
)
|
||
print(
|
||
" Image optimization: Enabled "
|
||
f"({image_mode}, JPEG q{image_quality} when re-encoded)"
|
||
)
|
||
else:
|
||
print(" Image optimization: Disabled (original bytes)")
|
||
elif use_compat_mode:
|
||
print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
|
||
print(f" PNG renderer: {renderer_name} {renderer_status}")
|
||
else:
|
||
print(f" Compatibility mode: Disabled (pure SVG)")
|
||
if transition:
|
||
canonical_transition, _transition_options = (
|
||
normalize_transition_effect_request(
|
||
transition,
|
||
transition_effect_options,
|
||
)
|
||
)
|
||
trans_name = (
|
||
NATIVE_TRANSITIONS.get(canonical_transition, {}).get(
|
||
'name',
|
||
canonical_transition,
|
||
)
|
||
if canonical_transition
|
||
else transition
|
||
)
|
||
print(f" Transition effect: {trans_name}")
|
||
if enable_notes and notes:
|
||
print(f" Speaker notes: {len(notes)} page(s)")
|
||
elif enable_notes:
|
||
print(f" Speaker notes: Enabled (no notes files found)")
|
||
else:
|
||
print(f" Speaker notes: Disabled")
|
||
print()
|
||
|
||
animation_cli_overrides = animation_cli_overrides or {}
|
||
|
||
temp_dir = _create_writable_work_dir(output_path)
|
||
|
||
try:
|
||
base_pptx = temp_dir / 'base.pptx'
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "preserve"
|
||
and native_structure_contract is not None
|
||
and template_specs is not None
|
||
):
|
||
_create_preserved_base_pptx(
|
||
native_structure_contract,
|
||
template_specs,
|
||
base_pptx,
|
||
(width_emu, height_emu),
|
||
)
|
||
else:
|
||
# Create the standard base PPTX with python-pptx.
|
||
prs = Presentation()
|
||
prs.slide_width = width_emu
|
||
prs.slide_height = height_emu
|
||
|
||
blank_layout = prs.slide_layouts[6]
|
||
for _ in svg_files:
|
||
prs.slides.add_slide(blank_layout)
|
||
prs.save(str(base_pptx))
|
||
|
||
# Extract PPTX
|
||
extract_dir = temp_dir / 'pptx_content'
|
||
with zipfile.ZipFile(base_pptx, 'r') as zf:
|
||
zf.extractall(extract_dir)
|
||
if use_native_shapes and pptx_structure == "preserve":
|
||
_clear_preserved_slide_collections(extract_dir)
|
||
active_theme_font_spec = (
|
||
theme_font_spec
|
||
if use_native_shapes
|
||
and pptx_structure in {"baseline", "flat", "structured"}
|
||
else None
|
||
)
|
||
if active_theme_font_spec is not None:
|
||
apply_theme_font_spec(extract_dir, active_theme_font_spec)
|
||
active_theme_color_spec = (
|
||
theme_color_spec
|
||
if use_native_shapes
|
||
and pptx_structure in {"baseline", "flat", "structured"}
|
||
else None
|
||
)
|
||
if active_theme_color_spec is not None:
|
||
apply_theme_color_spec(extract_dir, active_theme_color_spec)
|
||
structure = _read_slide_layout_targets(extract_dir, len(svg_files))
|
||
|
||
media_dir = extract_dir / 'ppt' / 'media'
|
||
media_dir.mkdir(exist_ok=True)
|
||
|
||
prerender_results: dict[int, bool] | None = None
|
||
if not use_native_shapes and use_compat_mode and PNG_RENDERER is not None:
|
||
if workers is None:
|
||
resolved_workers = min(os.cpu_count() or 2, len(svg_files), 8)
|
||
else:
|
||
resolved_workers = max(0, workers)
|
||
if verbose:
|
||
cache_label = str(cache_dir) if cache_dir else 'disabled'
|
||
mode = f'parallel x{resolved_workers}' if resolved_workers > 1 else 'sequential'
|
||
print(f" Pre-rendering PNGs ({mode}, cache: {cache_label})")
|
||
prerender_results = _prerender_legacy_pngs(
|
||
svg_files, media_dir, pixel_width, pixel_height,
|
||
cache_dir, resolved_workers, verbose,
|
||
)
|
||
if verbose:
|
||
print()
|
||
|
||
success_count = 0
|
||
has_any_image = False
|
||
media_cache: dict[tuple[str, str], str] = {}
|
||
image_exts_used: set[str] = set()
|
||
package_exts_used: set[str] = set()
|
||
package_content_overrides: dict[str, str] = {}
|
||
notes_slides_created: set[int] = set()
|
||
narration_slides_created: set[int] = set()
|
||
audio_exts_used: set[str] = set()
|
||
package_uses_timings = False
|
||
mixed_animation_offset = 0
|
||
config_defaults = _as_dict(_as_dict(animation_config).get('defaults'))
|
||
transition_defaults_value = config_defaults.get('transition', {})
|
||
if not isinstance(transition_defaults_value, dict):
|
||
raise ValueError(
|
||
'animations.json defaults transition must be an object'
|
||
)
|
||
default_transition_cfg = transition_defaults_value
|
||
animation_defaults_value = config_defaults.get('animation', {})
|
||
if not isinstance(animation_defaults_value, dict):
|
||
raise ValueError(
|
||
'animations.json defaults animation must be an object'
|
||
)
|
||
default_animation_cfg = animation_defaults_value
|
||
animation_seed = json.dumps(
|
||
{
|
||
'animation': animation,
|
||
'config': animation_config,
|
||
'slides': [path.name for path in svg_files],
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
separators=(',', ':'),
|
||
)
|
||
animation_rng = random.Random(animation_seed)
|
||
conversion_trace: list[dict[str, Any]] | None = [] if conversion_trace_path else None
|
||
structure_trace: list[dict[str, Any]] | None = (
|
||
[]
|
||
if use_native_shapes and pptx_structure in {"baseline", "structured", "preserve"}
|
||
else None
|
||
)
|
||
|
||
for i, svg_path in enumerate(svg_files, 1):
|
||
slide_num = i
|
||
is_layout_definition = slide_num > public_slide_count
|
||
progress_label = (
|
||
f"[Layout definition {slide_num - public_slide_count}/"
|
||
f"{len(definition_svg_files)}]"
|
||
if is_layout_definition
|
||
else f"[Slide {slide_num}/{public_slide_count}]"
|
||
)
|
||
expected_animation_targets: list[dict[str, Any]] = []
|
||
expected_animation_duration = animation_duration
|
||
expected_animation_trigger = normalize_animation_trigger(animation_trigger)
|
||
expected_transition_sound: dict[str, str] | None = None
|
||
|
||
try:
|
||
# ---- Native shapes mode ----
|
||
if use_native_shapes:
|
||
slide_cfg = (
|
||
{}
|
||
if is_layout_definition
|
||
else _slide_config(animation_config, svg_path.stem)
|
||
)
|
||
if is_layout_definition:
|
||
slide_transition = None
|
||
slide_transition_effect_options = {}
|
||
slide_transition_duration = transition_duration
|
||
slide_auto_advance = None
|
||
slide_transition_sound_path = None
|
||
slide_animation = None
|
||
slide_animation_duration = animation_duration
|
||
slide_animation_stagger = animation_stagger
|
||
slide_animation_trigger = animation_trigger
|
||
slide_animation_cfg = {}
|
||
else:
|
||
(
|
||
slide_transition,
|
||
slide_transition_effect_options,
|
||
slide_transition_duration,
|
||
slide_auto_advance,
|
||
slide_transition_sound_path,
|
||
) = _slide_transition_settings(
|
||
default_transition_cfg,
|
||
slide_cfg,
|
||
transition,
|
||
transition_effect_options,
|
||
transition_duration,
|
||
auto_advance,
|
||
transition_sound,
|
||
animation_cli_overrides,
|
||
)
|
||
(
|
||
slide_animation,
|
||
slide_animation_duration,
|
||
slide_animation_stagger,
|
||
slide_animation_trigger,
|
||
slide_animation_cfg,
|
||
) = _slide_animation_settings(
|
||
slide_cfg,
|
||
default_animation_cfg,
|
||
animation,
|
||
animation_duration,
|
||
animation_stagger,
|
||
animation_trigger,
|
||
animation_cli_overrides,
|
||
)
|
||
if morph_pairs_by_destination.get(svg_path.stem):
|
||
if (
|
||
slide_transition != "morph"
|
||
or slide_transition_effect_options.get(
|
||
"morph_by",
|
||
"object",
|
||
)
|
||
!= "object"
|
||
):
|
||
raise ValueError(
|
||
f'animations.json slide "{svg_path.stem}" '
|
||
'declares deterministic Morph pairs, but '
|
||
'the resolved transition is not Morph by object'
|
||
)
|
||
groups_value = slide_cfg.get('groups', {})
|
||
if not isinstance(groups_value, dict):
|
||
raise ValueError(
|
||
'animations.json slide groups must be an object'
|
||
)
|
||
animation_hard_disabled = (
|
||
animation_cli_overrides.get('animation', False)
|
||
and animation is None
|
||
)
|
||
explicit_group_ids: set[str] = set()
|
||
trigger_group_ids: set[str] = set()
|
||
if not animation_hard_disabled:
|
||
for group_id, group_cfg in groups_value.items():
|
||
if not isinstance(group_cfg, dict):
|
||
continue
|
||
group_path = (
|
||
f'slides['
|
||
f'{json.dumps(svg_path.stem, ensure_ascii=False)}'
|
||
f'].groups['
|
||
f'{json.dumps(str(group_id), ensure_ascii=False)}'
|
||
f']'
|
||
)
|
||
effect_entries = animation_group_effect_entries(
|
||
group_cfg,
|
||
path=group_path,
|
||
)
|
||
if any(
|
||
effect_cfg.get('effect') != 'none'
|
||
and (
|
||
slide_animation is not None
|
||
or 'effect' in effect_cfg
|
||
)
|
||
for _effect_path, effect_cfg in effect_entries
|
||
):
|
||
explicit_group_ids.add(str(group_id))
|
||
for _effect_path, effect_cfg in effect_entries:
|
||
trigger_shape = effect_cfg.get('trigger_shape')
|
||
if (
|
||
isinstance(trigger_shape, str)
|
||
and trigger_shape.strip()
|
||
):
|
||
trigger_group_ids.add(trigger_shape)
|
||
explicit_animation_groups = frozenset(
|
||
explicit_group_ids | trigger_group_ids
|
||
)
|
||
converter_group_overrides = (
|
||
explicit_animation_groups
|
||
| frozenset(
|
||
morph_group_overrides_by_slide.get(
|
||
svg_path.stem,
|
||
set(),
|
||
)
|
||
)
|
||
)
|
||
(
|
||
slide_xml,
|
||
media_files_dict,
|
||
rel_entries,
|
||
anim_targets,
|
||
package_files_dict,
|
||
content_type_overrides,
|
||
) = (
|
||
convert_svg_to_slide_shapes(
|
||
svg_path, slide_num=slide_num, verbose=verbose,
|
||
text_flow=text_flow,
|
||
image_optimize=image_optimize,
|
||
image_max_dimension=image_max_dimension,
|
||
image_sizing=image_sizing,
|
||
image_scale=image_scale,
|
||
image_quality=image_quality,
|
||
native_objects=native_objects,
|
||
animation_group_overrides=converter_group_overrides,
|
||
theme_font_spec=active_theme_font_spec,
|
||
theme_color_spec=active_theme_color_spec,
|
||
primary_language=primary_language,
|
||
promote_background=pptx_structure != "structured",
|
||
trace_out=conversion_trace
|
||
if conversion_trace is not None
|
||
else structure_trace,
|
||
)
|
||
)
|
||
morph_group_ids = morph_group_overrides_by_slide.get(
|
||
svg_path.stem,
|
||
set(),
|
||
)
|
||
if morph_group_ids:
|
||
target_ids_by_group: dict[str, list[int]] = {}
|
||
for shape_id, group_id in anim_targets:
|
||
target_ids_by_group.setdefault(
|
||
str(group_id),
|
||
[],
|
||
).append(int(shape_id))
|
||
for group_id in sorted(morph_group_ids):
|
||
resolved_shape_ids = target_ids_by_group.get(
|
||
group_id,
|
||
[],
|
||
)
|
||
if len(resolved_shape_ids) != 1:
|
||
raise ValueError(
|
||
f'Morph target "{svg_path.stem}/{group_id}" '
|
||
'must resolve to exactly one Slide-local '
|
||
'PowerPoint shape'
|
||
)
|
||
morph_shape_ids[
|
||
(svg_path.stem, group_id)
|
||
] = resolved_shape_ids[0]
|
||
# Order matters: OOXML schema requires <p:transition>
|
||
# to precede <p:timing> inside <p:sld>. Both use the same
|
||
# </p:sld> string-replace anchor, so transition must be
|
||
# injected first and timing second.
|
||
packaged_sounds_by_source: dict[Path, tuple[str, str]] = {}
|
||
expected_transition_sound = _materialize_transition_sound(
|
||
(
|
||
animation_resource_root
|
||
if animation_resource_root is not None
|
||
else svg_files[0].parent.parent
|
||
),
|
||
slide_transition_sound_path,
|
||
media_files_dict,
|
||
rel_entries,
|
||
audio_exts_used,
|
||
packaged_sounds_by_source,
|
||
)
|
||
if (
|
||
slide_transition is not None
|
||
or slide_auto_advance is not None
|
||
or expected_transition_sound is not None
|
||
):
|
||
transition_fragment = create_transition_xml(
|
||
effect=slide_transition,
|
||
duration=slide_transition_duration,
|
||
advance_after=slide_auto_advance,
|
||
effect_options=slide_transition_effect_options,
|
||
sound=expected_transition_sound,
|
||
)
|
||
if transition_fragment:
|
||
slide_xml = slide_xml.replace(
|
||
'</p:sld>',
|
||
'\n' + transition_fragment + '\n</p:sld>',
|
||
)
|
||
if slide_auto_advance is not None:
|
||
package_uses_timings = True
|
||
|
||
expected_animation_duration = slide_animation_duration
|
||
expected_animation_trigger = slide_animation_trigger
|
||
if (
|
||
not animation_hard_disabled
|
||
and (slide_animation or explicit_animation_groups)
|
||
and anim_targets
|
||
):
|
||
seq_targets, mixed_count = _build_sequence_targets(
|
||
anim_targets,
|
||
svg_path.stem,
|
||
slide_cfg,
|
||
slide_animation,
|
||
slide_animation_cfg,
|
||
slide_animation_duration,
|
||
slide_animation_stagger,
|
||
mixed_animation_offset,
|
||
animation_rng,
|
||
)
|
||
seq_targets = _materialize_animation_sounds(
|
||
(
|
||
animation_resource_root
|
||
if animation_resource_root is not None
|
||
else svg_files[0].parent.parent
|
||
),
|
||
seq_targets,
|
||
media_files_dict,
|
||
rel_entries,
|
||
audio_exts_used,
|
||
packaged_sounds_by_source,
|
||
)
|
||
expected_animation_targets = seq_targets
|
||
if mixed_count:
|
||
mixed_animation_offset += mixed_count
|
||
timing_xml = '\n' + create_sequence_timing_xml(
|
||
seq_targets, duration=slide_animation_duration,
|
||
trigger=slide_animation_trigger,
|
||
)
|
||
slide_xml = slide_xml.replace(
|
||
'</p:sld>',
|
||
timing_xml + '\n</p:sld>',
|
||
)
|
||
|
||
# Write slide XML
|
||
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
|
||
with open(slide_xml_path, 'w', encoding='utf-8') as f:
|
||
f.write(slide_xml)
|
||
|
||
# Write media files
|
||
media_name_map: dict[str, str] = {}
|
||
for media_name, media_data in media_files_dict.items():
|
||
ext = media_name.rsplit('.', 1)[-1].lower()
|
||
media_hash = hashlib.sha256(media_data).hexdigest()
|
||
cache_key = (ext, media_hash)
|
||
cached_name = media_cache.get(cache_key)
|
||
|
||
if cached_name is None:
|
||
prefix = (
|
||
'audio'
|
||
if f'.{ext}' in AUDIO_CONTENT_TYPES
|
||
else 'image'
|
||
)
|
||
cached_name = f'{prefix}_{media_hash[:16]}.{ext}'
|
||
media_cache[cache_key] = cached_name
|
||
with open(media_dir / cached_name, 'wb') as f:
|
||
f.write(media_data)
|
||
|
||
media_name_map[media_name] = cached_name
|
||
|
||
for rel in rel_entries:
|
||
target = rel.get('target', '')
|
||
if not target.startswith('../media/'):
|
||
continue
|
||
media_name = target.split('../media/', 1)[1]
|
||
mapped_name = media_name_map.get(media_name)
|
||
if mapped_name:
|
||
rel['target'] = f'../media/{mapped_name}'
|
||
|
||
# Write non-media OOXML package parts produced by native
|
||
# object converters, e.g. chart XML, chart rels, and
|
||
# embedded workbooks.
|
||
for part_name, part_data in package_files_dict.items():
|
||
if (
|
||
part_name.startswith('ppt/charts/')
|
||
and part_name.endswith('.xml')
|
||
):
|
||
part_data = rewrite_chart_accent_colors(
|
||
part_data,
|
||
active_theme_color_spec,
|
||
)
|
||
package_path = extract_dir / part_name
|
||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with open(package_path, 'wb') as f:
|
||
f.write(part_data)
|
||
suffix = package_path.suffix.lstrip('.').lower()
|
||
if suffix:
|
||
package_exts_used.add(suffix)
|
||
package_content_overrides.update(content_type_overrides)
|
||
|
||
# Build relationships XML
|
||
rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
|
||
rels_dir.mkdir(exist_ok=True)
|
||
rels_path = rels_dir / f'slide{slide_num}.xml.rels'
|
||
|
||
extra_rels = ''
|
||
for rel in rel_entries:
|
||
extra_rels += (
|
||
f'\n <Relationship Id="{rel["id"]}" '
|
||
f'Type="{rel["type"]}" Target="{rel["target"]}"/>'
|
||
)
|
||
|
||
rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||
<Relationship Id="rId1"
|
||
Type="{SLIDE_LAYOUT_REL_TYPE}"
|
||
Target="{structure.slide_layout_target(slide_num)}"/>{extra_rels}
|
||
</Relationships>'''
|
||
with open(rels_path, 'w', encoding='utf-8') as f:
|
||
f.write(rels_xml)
|
||
|
||
# Track image formats for Content_Types
|
||
for media_name in media_name_map.values():
|
||
ext = media_name.rsplit('.', 1)[-1].lower()
|
||
dotted_ext = f'.{ext}'
|
||
if dotted_ext in AUDIO_CONTENT_TYPES:
|
||
audio_exts_used.add(dotted_ext)
|
||
else:
|
||
_content_type_for_extension(ext)
|
||
image_exts_used.add(ext)
|
||
has_any_image = True
|
||
|
||
# ---- Legacy SVG embedding mode ----
|
||
else:
|
||
slide_cfg = _slide_config(animation_config, svg_path.stem)
|
||
(
|
||
slide_transition,
|
||
slide_transition_effect_options,
|
||
slide_transition_duration,
|
||
slide_auto_advance,
|
||
slide_transition_sound_path,
|
||
) = (
|
||
_slide_transition_settings(
|
||
default_transition_cfg,
|
||
slide_cfg,
|
||
transition,
|
||
transition_effect_options,
|
||
transition_duration,
|
||
auto_advance,
|
||
transition_sound,
|
||
animation_cli_overrides,
|
||
)
|
||
)
|
||
svg_filename = f'image{i}.svg'
|
||
png_filename = f'image{i}.png'
|
||
png_rid = 'rId2'
|
||
svg_rid = 'rId3' if use_compat_mode else 'rId2'
|
||
|
||
shutil.copy(svg_path, media_dir / svg_filename)
|
||
|
||
slide_has_png = False
|
||
if use_compat_mode:
|
||
if prerender_results is not None:
|
||
png_success = prerender_results.get(i, False)
|
||
else:
|
||
png_path = media_dir / png_filename
|
||
png_success = convert_svg_to_png(
|
||
svg_path, png_path,
|
||
width=pixel_width, height=pixel_height,
|
||
)
|
||
if png_success:
|
||
slide_has_png = True
|
||
has_any_image = True
|
||
image_exts_used.add('png')
|
||
else:
|
||
if verbose:
|
||
print(
|
||
f" [{i}/{len(svg_files)}] {svg_path.name} - "
|
||
"PNG generation failed, using pure SVG"
|
||
)
|
||
svg_rid = 'rId2'
|
||
|
||
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
|
||
slide_xml = create_slide_xml_with_svg(
|
||
slide_num,
|
||
png_rid=png_rid, svg_rid=svg_rid,
|
||
width_emu=width_emu, height_emu=height_emu,
|
||
transition=slide_transition,
|
||
transition_effect_options=slide_transition_effect_options,
|
||
transition_duration=slide_transition_duration,
|
||
auto_advance=slide_auto_advance,
|
||
use_compat_mode=(use_compat_mode and slide_has_png),
|
||
)
|
||
with open(slide_xml_path, 'w', encoding='utf-8') as f:
|
||
f.write(slide_xml)
|
||
|
||
rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
|
||
rels_dir.mkdir(exist_ok=True)
|
||
rels_path = rels_dir / f'slide{slide_num}.xml.rels'
|
||
rels_xml = create_slide_rels_xml(
|
||
png_rid=png_rid, png_filename=png_filename,
|
||
svg_rid=svg_rid, svg_filename=svg_filename,
|
||
use_compat_mode=(use_compat_mode and slide_has_png),
|
||
slide_layout_target=structure.slide_layout_target(slide_num),
|
||
)
|
||
with open(rels_path, 'w', encoding='utf-8') as f:
|
||
f.write(rels_xml)
|
||
|
||
resolved_advance_after = slide_auto_advance
|
||
resolved_advance_on_click = True
|
||
|
||
# --- Process notes (shared between native and legacy mode) ---
|
||
notes_content = ''
|
||
if enable_notes and not is_layout_definition:
|
||
svg_stem = svg_path.stem
|
||
notes_content = notes.get(svg_stem, '') if notes else ''
|
||
notes_text = markdown_to_plain_text(notes_content) if notes_content else ''
|
||
if notes_text:
|
||
_ensure_notes_master(extract_dir, primary_language)
|
||
|
||
notes_slides_dir = extract_dir / 'ppt' / 'notesSlides'
|
||
notes_slides_dir.mkdir(exist_ok=True)
|
||
|
||
notes_xml_path = notes_slides_dir / f'notesSlide{slide_num}.xml'
|
||
notes_xml = create_notes_slide_xml(
|
||
slide_num,
|
||
notes_text,
|
||
primary_language,
|
||
)
|
||
with open(notes_xml_path, 'w', encoding='utf-8') as f:
|
||
f.write(notes_xml)
|
||
|
||
notes_rels_dir = notes_slides_dir / '_rels'
|
||
notes_rels_dir.mkdir(exist_ok=True)
|
||
notes_rels_path = notes_rels_dir / f'notesSlide{slide_num}.xml.rels'
|
||
notes_rels_xml = create_notes_slide_rels_xml(slide_num)
|
||
with open(notes_rels_path, 'w', encoding='utf-8') as f:
|
||
f.write(notes_rels_xml)
|
||
|
||
_append_relationship(
|
||
rels_path,
|
||
'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide',
|
||
f'../notesSlides/notesSlide{slide_num}.xml',
|
||
)
|
||
notes_slides_created.add(slide_num)
|
||
|
||
# --- Process narration audio (shared between native and legacy mode) ---
|
||
svg_stem = svg_path.stem
|
||
audio_path = (
|
||
narration_audio.get(svg_stem)
|
||
if narration_audio and not is_layout_definition
|
||
else None
|
||
)
|
||
if audio_path:
|
||
slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
|
||
rels_path = extract_dir / 'ppt' / 'slides' / '_rels' / f'slide{slide_num}.xml.rels'
|
||
|
||
ext = audio_path.suffix.lower()
|
||
media_name = f'narration{slide_num}{ext}'
|
||
shutil.copy2(audio_path, media_dir / media_name)
|
||
audio_exts_used.add(ext)
|
||
|
||
poster_name = 'narration_poster.png'
|
||
poster_path = media_dir / poster_name
|
||
if not poster_path.exists():
|
||
poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES)
|
||
has_any_image = True
|
||
image_exts_used.add('png')
|
||
|
||
media_rid = _append_relationship(
|
||
rels_path,
|
||
MEDIA_REL_TYPE,
|
||
f'../media/{media_name}',
|
||
)
|
||
audio_rid = _append_relationship(
|
||
rels_path,
|
||
AUDIO_REL_TYPE,
|
||
f'../media/{media_name}',
|
||
)
|
||
poster_rid = _append_relationship(
|
||
rels_path,
|
||
IMAGE_REL_TYPE,
|
||
f'../media/{poster_name}',
|
||
)
|
||
|
||
slide_xml = slide_xml_path.read_text(encoding='utf-8')
|
||
narration_shape_id = next_shape_id(slide_xml)
|
||
narration_transition_duration = (
|
||
slide_transition_duration
|
||
if slide_transition is not None
|
||
else 0.0
|
||
)
|
||
narration_lead_in = narration_lead_in_seconds(
|
||
narration_transition_duration,
|
||
start_floor=narration_start_floor,
|
||
)
|
||
slide_xml = inject_narration(
|
||
slide_xml,
|
||
shape_id=narration_shape_id,
|
||
shape_name=media_name,
|
||
audio_rid=audio_rid,
|
||
media_rid=media_rid,
|
||
poster_rid=poster_rid,
|
||
start_delay=narration_lead_in,
|
||
)
|
||
|
||
if use_narration_timings:
|
||
duration = probe_audio_duration(audio_path)
|
||
if duration is None:
|
||
raise RuntimeError(
|
||
f"Unable to read narration duration with ffprobe: {audio_path}"
|
||
)
|
||
narration_advance_after = (
|
||
narration_lead_in + duration + narration_padding
|
||
)
|
||
slide_xml = apply_recorded_timing(
|
||
slide_xml,
|
||
advance_after=narration_advance_after,
|
||
transition_duration=slide_transition_duration,
|
||
transition_effect=slide_transition,
|
||
)
|
||
resolved_advance_after = narration_advance_after
|
||
resolved_advance_on_click = False
|
||
package_uses_timings = True
|
||
slide_xml_path.write_text(slide_xml, encoding='utf-8')
|
||
narration_slides_created.add(slide_num)
|
||
|
||
final_slide_xml = slide_xml_path.read_text(encoding='utf-8')
|
||
try:
|
||
resolved_motion = validate_generated_transition_xml(
|
||
final_slide_xml,
|
||
effect=slide_transition,
|
||
effect_options=slide_transition_effect_options,
|
||
duration=slide_transition_duration,
|
||
advance_on_click=resolved_advance_on_click,
|
||
advance_after=resolved_advance_after,
|
||
sound=expected_transition_sound,
|
||
)
|
||
except ValueError as exc:
|
||
raise RuntimeError(
|
||
f'Slide {slide_num} transition validation failed: {exc}'
|
||
) from exc
|
||
try:
|
||
resolved_animation = validate_generated_animation_xml(
|
||
final_slide_xml,
|
||
expected_animation_targets,
|
||
duration=expected_animation_duration,
|
||
trigger=expected_animation_trigger,
|
||
)
|
||
except ValueError as exc:
|
||
raise RuntimeError(
|
||
f'Slide {slide_num} animation validation failed: {exc}'
|
||
) from exc
|
||
|
||
if conversion_trace is not None:
|
||
motion_summary = asdict(resolved_motion)
|
||
for trace_entry in reversed(conversion_trace):
|
||
if trace_entry.get('slide_num') == slide_num:
|
||
trace_entry['motion'] = motion_summary
|
||
trace_entry['animation'] = asdict(resolved_animation)
|
||
break
|
||
|
||
if verbose:
|
||
if use_native_shapes:
|
||
mode_str = " (Native)"
|
||
elif use_compat_mode and not use_native_shapes:
|
||
mode_str = " (PNG+SVG)" if has_any_image else " (SVG)"
|
||
else:
|
||
mode_str = " (SVG)"
|
||
has_notes = slide_num in notes_slides_created
|
||
notes_str = " +notes" if has_notes else ""
|
||
narration_str = " +narration" if slide_num in narration_slides_created else ""
|
||
print(
|
||
f" {progress_label} {svg_path.name}{mode_str}"
|
||
f"{notes_str}{narration_str}"
|
||
)
|
||
|
||
success_count += 1
|
||
|
||
except Exception as e:
|
||
if verbose:
|
||
print(
|
||
f" {progress_label} {svg_path.name} - Error: {e}"
|
||
)
|
||
if use_native_shapes:
|
||
raise
|
||
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "baseline"
|
||
and success_count == len(svg_files)
|
||
):
|
||
_convert_page_number_texts_to_fields(
|
||
extract_dir,
|
||
len(svg_files),
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
context="Baseline",
|
||
verbose=verbose,
|
||
)
|
||
_promote_common_slide_backgrounds_to_masters(
|
||
extract_dir,
|
||
structure,
|
||
len(svg_files),
|
||
verbose=verbose,
|
||
)
|
||
_promote_common_chrome_shapes_to_masters(
|
||
extract_dir,
|
||
structure,
|
||
len(svg_files),
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
verbose=verbose,
|
||
)
|
||
_extract_baseline_layout_families(
|
||
extract_dir,
|
||
structure,
|
||
svg_files,
|
||
verbose=verbose,
|
||
)
|
||
_promote_common_chrome_shapes_to_layouts(
|
||
extract_dir,
|
||
len(svg_files),
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
verbose=verbose,
|
||
)
|
||
_prune_unused_slide_layouts(
|
||
extract_dir,
|
||
structure,
|
||
len(svg_files),
|
||
verbose=verbose,
|
||
)
|
||
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "flat"
|
||
and success_count == len(svg_files)
|
||
):
|
||
_prepare_flat_structure(
|
||
extract_dir,
|
||
structure,
|
||
len(svg_files),
|
||
master_text_style_spec,
|
||
structure_name,
|
||
verbose=verbose,
|
||
)
|
||
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "structured"
|
||
and success_count == len(svg_files)
|
||
):
|
||
_convert_page_number_texts_to_fields(
|
||
extract_dir,
|
||
len(svg_files),
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
context="Structured",
|
||
verbose=verbose,
|
||
)
|
||
if template_specs is None:
|
||
raise TemplateStructureError(
|
||
"Structured metadata was not parsed before export"
|
||
)
|
||
(
|
||
template_background_expectations,
|
||
template_shape_roster_expectations,
|
||
template_layout_parts_by_key,
|
||
template_master_parts_by_key,
|
||
) = _apply_explicit_layout_structure(
|
||
extract_dir,
|
||
structure,
|
||
template_specs,
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
active_theme_font_spec,
|
||
use_layout_placeholder_frames=use_layout_placeholder_frames,
|
||
verbose=verbose,
|
||
)
|
||
master_count = apply_master_text_style_spec(
|
||
extract_dir,
|
||
master_text_style_spec,
|
||
)
|
||
if verbose:
|
||
print(
|
||
" Structured master text styles: "
|
||
f"{master_count} master(s), "
|
||
f"title {master_text_style_spec.title_hpt / 100:g}pt, "
|
||
"body levels "
|
||
f"{master_text_style_spec.body_levels_hpt[0] / 100:g}–"
|
||
f"{master_text_style_spec.body_levels_hpt[-1] / 100:g}pt"
|
||
)
|
||
_prune_unused_slide_layouts(
|
||
extract_dir,
|
||
structure,
|
||
len(svg_files),
|
||
verbose=verbose,
|
||
)
|
||
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "preserve"
|
||
and success_count == len(svg_files)
|
||
):
|
||
_convert_page_number_texts_to_fields(
|
||
extract_dir,
|
||
len(svg_files),
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
context="Preserve",
|
||
verbose=verbose,
|
||
)
|
||
if template_specs is None or native_structure_contract is None:
|
||
raise TemplateStructureError(
|
||
"Preserved structure metadata was not parsed before export"
|
||
)
|
||
_apply_preserved_structure(
|
||
extract_dir,
|
||
template_specs,
|
||
native_structure_contract,
|
||
conversion_trace if conversion_trace is not None else structure_trace,
|
||
verbose=verbose,
|
||
)
|
||
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "structured"
|
||
and definition_svg_files
|
||
and success_count == total_slide_count
|
||
):
|
||
removed = _remove_trailing_layout_definition_slides(
|
||
extract_dir,
|
||
public_slide_count,
|
||
total_slide_count,
|
||
)
|
||
pruned_payload_parts = _prune_unreferenced_definition_payload_parts(
|
||
extract_dir
|
||
)
|
||
for slide_num in range(public_slide_count + 1, total_slide_count + 1):
|
||
slide_part = f"ppt/slides/slide{slide_num}.xml"
|
||
if template_background_expectations is not None:
|
||
template_background_expectations.pop(slide_part, None)
|
||
if template_shape_roster_expectations is not None:
|
||
template_shape_roster_expectations.pop(slide_part, None)
|
||
if verbose:
|
||
print(
|
||
" Layout definition carriers: "
|
||
f"removed {removed} internal slide(s), pruned "
|
||
f"{pruned_payload_parts} orphan payload part(s)"
|
||
)
|
||
|
||
morph_trace_names = _apply_morph_shape_names(
|
||
extract_dir,
|
||
morph_pairs,
|
||
public_slide_numbers,
|
||
morph_shape_ids,
|
||
)
|
||
if template_shape_roster_expectations is not None:
|
||
for slide_number in morph_trace_names:
|
||
slide_part = f"ppt/slides/slide{slide_number}.xml"
|
||
template_shape_roster_expectations[
|
||
slide_part
|
||
] = _top_level_shape_name_roster(
|
||
ET.parse(extract_dir / slide_part).getroot()
|
||
)
|
||
if conversion_trace is not None:
|
||
for trace_entry in conversion_trace:
|
||
slide_number = int(trace_entry.get("slide_num", 0))
|
||
names = morph_trace_names.get(slide_number)
|
||
if names:
|
||
trace_entry["morph_names"] = dict(sorted(names.items()))
|
||
|
||
# Update [Content_Types].xml
|
||
content_types_path = extract_dir / '[Content_Types].xml'
|
||
with open(content_types_path, 'r', encoding='utf-8') as f:
|
||
content_types = f.read()
|
||
|
||
if not use_native_shapes:
|
||
content_types = _add_default_content_type(content_types, 'svg', 'image/svg+xml')
|
||
for ext in sorted(image_exts_used):
|
||
content_types = _add_default_content_type(
|
||
content_types,
|
||
ext,
|
||
_content_type_for_extension(ext),
|
||
)
|
||
if 'xlsx' in package_exts_used:
|
||
content_types = _add_default_content_type(
|
||
content_types,
|
||
'xlsx',
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
)
|
||
for part_name, content_type in sorted(package_content_overrides.items()):
|
||
content_types = _add_content_type_override(content_types, part_name, content_type)
|
||
with open(content_types_path, 'w', encoding='utf-8') as f:
|
||
f.write(content_types)
|
||
|
||
if audio_exts_used:
|
||
for ext in sorted(audio_exts_used):
|
||
content_type = AUDIO_CONTENT_TYPES.get(ext)
|
||
if content_type:
|
||
content_types = _add_default_content_type(
|
||
content_types,
|
||
ext.removeprefix('.'),
|
||
content_type,
|
||
)
|
||
if 'Extension="png"' not in content_types:
|
||
content_types = _add_default_content_type(content_types, 'png', 'image/png')
|
||
with open(content_types_path, 'w', encoding='utf-8') as f:
|
||
f.write(content_types)
|
||
|
||
# Add notes master / slides content types
|
||
if enable_notes and notes_slides_created:
|
||
notes_theme_override = (
|
||
' <Override PartName="/ppt/theme/theme2.xml" '
|
||
'ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
|
||
)
|
||
if notes_theme_override not in content_types:
|
||
content_types = content_types.replace(
|
||
'</Types>',
|
||
notes_theme_override + '\n</Types>',
|
||
)
|
||
notes_master_override = (
|
||
' <Override PartName="/ppt/notesMasters/notesMaster1.xml" '
|
||
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
|
||
)
|
||
if notes_master_override not in content_types:
|
||
content_types = content_types.replace(
|
||
'</Types>',
|
||
notes_master_override + '\n</Types>',
|
||
)
|
||
for i in sorted(notes_slides_created):
|
||
override = (
|
||
f' <Override PartName="/ppt/notesSlides/notesSlide{i}.xml" '
|
||
f'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>'
|
||
)
|
||
if override not in content_types:
|
||
content_types = content_types.replace('</Types>', override + '\n</Types>')
|
||
with open(content_types_path, 'w', encoding='utf-8') as f:
|
||
f.write(content_types)
|
||
|
||
if package_uses_timings:
|
||
set_directory_use_timings(extract_dir)
|
||
|
||
rels_problems = verify_internal_relationships(extract_dir)
|
||
if rels_problems:
|
||
details = '\n'.join(f' - {p}' for p in rels_problems)
|
||
raise RuntimeError(
|
||
'PPTX package contains dangling internal relationship targets; '
|
||
'PowerPoint will report the file as corrupt:\n' + details
|
||
)
|
||
|
||
# Replace the python-pptx base-template metadata (stale "Steve Canny"
|
||
# author, 2013 dates, "generated using python-pptx", Slides=0) with
|
||
# accurate, tool-neutral document properties.
|
||
pres_format = _presentation_format(width_emu, height_emu)
|
||
effective_doc_metadata = dict(doc_metadata or {})
|
||
if primary_language is not None:
|
||
effective_doc_metadata['language'] = primary_language
|
||
_stamp_docprops(
|
||
extract_dir,
|
||
public_slide_count,
|
||
pres_format,
|
||
effective_doc_metadata,
|
||
)
|
||
|
||
# Repackage PPTX to a temporary file first. The public output path is
|
||
# replaced only after every slide and relationship has succeeded.
|
||
temp_output_path = temp_dir / 'result.pptx'
|
||
with zipfile.ZipFile(temp_output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||
for file_path in extract_dir.rglob('*'):
|
||
if file_path.is_file():
|
||
arcname = file_path.relative_to(extract_dir)
|
||
zf.write(file_path, arcname)
|
||
if (
|
||
use_native_shapes
|
||
and pptx_structure == "structured"
|
||
and success_count == len(svg_files)
|
||
):
|
||
if template_specs is None or public_template_specs is None:
|
||
raise TemplateStructureError(
|
||
"Explicit Layout metadata was not parsed before validation"
|
||
)
|
||
try:
|
||
validate_pptx_template_package(
|
||
temp_output_path,
|
||
public_template_specs,
|
||
layout_specs=template_specs,
|
||
expected_layout_parts=template_layout_parts_by_key,
|
||
expected_master_parts=template_master_parts_by_key,
|
||
expected_backgrounds=template_background_expectations,
|
||
expected_shape_rosters=template_shape_roster_expectations,
|
||
)
|
||
except ValueError as exc:
|
||
raise TemplateStructureError(
|
||
f"PPTX structured package validation failed: {exc}"
|
||
) from exc
|
||
try:
|
||
validate_pptx_transition_package(
|
||
temp_output_path,
|
||
require_use_timings=package_uses_timings,
|
||
)
|
||
except ValueError as exc:
|
||
raise RuntimeError(
|
||
f'PPTX transition package validation failed: {exc}'
|
||
) from exc
|
||
try:
|
||
validate_pptx_morph_pairs(
|
||
temp_output_path,
|
||
morph_expectations,
|
||
)
|
||
except ValueError as exc:
|
||
raise RuntimeError(
|
||
f'PPTX Morph package validation failed: {exc}'
|
||
) from exc
|
||
try:
|
||
validate_pptx_animation_package(
|
||
temp_output_path,
|
||
require_supported_effects=True,
|
||
)
|
||
except ValueError as exc:
|
||
raise RuntimeError(
|
||
f'PPTX animation package validation failed: {exc}'
|
||
) from exc
|
||
shutil.move(str(temp_output_path), str(output_path))
|
||
permission_warnings = _relax_output_permissions(output_path)
|
||
|
||
if conversion_trace_path and conversion_trace is not None:
|
||
conversion_trace_path.parent.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
'output': str(output_path),
|
||
'slide_count': public_slide_count,
|
||
'slides': [
|
||
entry
|
||
for entry in conversion_trace
|
||
if int(entry.get('slide_num', 0)) <= public_slide_count
|
||
],
|
||
}
|
||
conversion_trace_path.write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||
encoding='utf-8',
|
||
)
|
||
|
||
if verbose:
|
||
print()
|
||
print(f"[Done] Saved: {output_path}")
|
||
for warning in permission_warnings:
|
||
print(f" [warn] {warning}")
|
||
if conversion_trace_path and conversion_trace is not None:
|
||
print(f" Trace: {conversion_trace_path}")
|
||
print(
|
||
f" Slides: {public_slide_count}; "
|
||
f"Layout definitions: {len(definition_svg_files)}"
|
||
)
|
||
if use_compat_mode and has_any_image:
|
||
print(f" Mode: Office compatibility mode (supports all Office versions)")
|
||
if PNG_RENDERER == 'svglib' and renderer_hint:
|
||
print(f" [Tip] {renderer_hint}")
|
||
|
||
return success_count == len(svg_files)
|
||
|
||
finally:
|
||
shutil.rmtree(temp_dir, ignore_errors=True)
|