Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,250 @@
"""apply: clone selected source slides into a new PPTX and write replacements.
Orchestrates the per-stage helpers (text / table / chart / transition / notes)
and rebuilds the presentation slide list, relationships, and content types.
"""
from __future__ import annotations
import tempfile
import zipfile
from pathlib import Path
from typing import Any
from xml.etree import ElementTree as ET
from pptx_animations import (
object_animation_fingerprint,
validate_pptx_animation_package,
)
from pptx_transitions import (
parse_source_xml,
serialize_source_xml,
set_package_use_timings,
validate_pptx_transition_package,
)
from .chart_fill import (
_apply_chart_edits_to_slide_package,
_max_chart_part_number,
_max_embedding_part_number,
)
from .clone import _make_part_allocator, deep_clone_slide_private_parts
from .notes import _find_notes_master_target, _slide_rels_with_notes
from .ooxml import NS, REL_NS, SLIDE_REL_TYPE, _parse_slide_refs, _qn, _xml_bytes
from .package import (
_add_notes_override,
_add_slide_override,
_content_type_root,
_empty_relationships_root,
_max_numeric_rid,
_max_slide_id,
_max_slide_part_number,
_prune_unreferenced_parts,
)
from .table_fill import _apply_table_edits_to_slide
from .text_fill import _apply_replacements_to_slide
from .transitions import (
DEFAULT_TRANSITION,
DEFAULT_TRANSITION_DURATION,
_resolve_slide_transition,
_set_slide_transition,
)
def apply_plan(
pptx_path: Path,
plan: dict[str, Any],
output_path: Path,
*,
transition: str | None = DEFAULT_TRANSITION,
transition_duration: float = DEFAULT_TRANSITION_DURATION,
) -> None:
"""Create a filled PPTX by cloning selected source slides and replacing text."""
plan_slides = plan.get("slides")
if not isinstance(plan_slides, list) or not plan_slides:
raise RuntimeError("Plan must contain a non-empty 'slides' list")
with zipfile.ZipFile(pptx_path) as zf:
entries = {info.filename: zf.read(info.filename) for info in zf.infolist() if not info.is_dir()}
slide_refs = {slide.index: slide for slide in _parse_slide_refs(zf)}
notes_master_target = _find_notes_master_target(entries)
pres_root = ET.fromstring(entries["ppt/presentation.xml"])
pres_rels_root = ET.fromstring(entries["ppt/_rels/presentation.xml.rels"])
content_root = _content_type_root(ET.fromstring(entries["[Content_Types].xml"]))
sld_id_lst = pres_root.find("p:sldIdLst", NS)
if sld_id_lst is None:
sld_id_lst = ET.SubElement(pres_root, _qn(NS["p"], "sldIdLst"))
for child in list(sld_id_lst):
sld_id_lst.remove(child)
for rel in list(pres_rels_root.findall(_qn(REL_NS, "Relationship"))):
if rel.attrib.get("Type") == SLIDE_REL_TYPE:
pres_rels_root.remove(rel)
next_slide_number = _max_slide_part_number(entries) + 1
next_slide_id = _max_slide_id(sld_id_lst) + 1
next_rel_number = _max_numeric_rid(pres_rels_root) + 1
next_chart_number = _max_chart_part_number(entries)
next_embedding_number = _max_embedding_part_number(entries)
allocate_part = _make_part_allocator(entries)
wrote_auto_advance = False
for offset, item in enumerate(plan_slides):
source_slide = int(item.get("source_slide", 0))
if source_slide not in slide_refs:
raise RuntimeError(f"Plan references a missing source slide: {source_slide}")
source_ref = slide_refs[source_slide]
new_slide_number = next_slide_number + offset
new_part = f"ppt/slides/slide{new_slide_number}.xml"
new_rels = f"ppt/slides/_rels/slide{new_slide_number}.xml.rels"
new_rid = f"rId{next_rel_number + offset}"
source_slide_xml = entries[source_ref.part_name]
source_animation_fingerprint = object_animation_fingerprint(
source_slide_xml
)
slide_root = parse_source_xml(source_slide_xml)
replacements = item.get("replacements", [])
if not isinstance(replacements, list):
raise RuntimeError(f"Slide {source_slide} replacements must be a list")
_apply_replacements_to_slide(
slide_root,
source_slide=source_slide,
replacements=replacements,
)
table_edits = item.get("table_edits", [])
if not isinstance(table_edits, list):
raise RuntimeError(f"Slide {source_slide} table_edits must be a list")
_apply_table_edits_to_slide(
slide_root,
source_slide=source_slide,
table_edits=table_edits,
)
(
slide_effect,
slide_effect_options,
slide_duration,
slide_advance,
) = _resolve_slide_transition(
item,
default_effect=transition,
default_duration=transition_duration,
)
slide_has_auto_advance = _set_slide_transition(
slide_root,
effect=slide_effect,
effect_options=slide_effect_options,
duration=slide_duration,
advance_after=slide_advance,
)
if slide_advance is not None and slide_has_auto_advance:
wrote_auto_advance = True
source_rels = entries.get(source_ref.rels_name)
slide_rels_root = ET.fromstring(source_rels) if source_rels else _empty_relationships_root()
deep_clone_slide_private_parts(
slide_rels_root,
new_slide_part=new_part,
entries=entries,
content_root=content_root,
allocate=allocate_part,
)
chart_edits = item.get("chart_edits", [])
if not isinstance(chart_edits, list):
raise RuntimeError(f"Slide {source_slide} chart_edits must be a list")
next_chart_number, next_embedding_number = _apply_chart_edits_to_slide_package(
slide_root,
slide_rels_root,
entries,
content_root,
source_slide=source_slide,
new_slide_part=new_part,
chart_edits=chart_edits,
next_chart_number=next_chart_number,
next_embedding_number=next_embedding_number,
)
try:
serialized_slide = serialize_source_xml(
slide_root,
source_slide_xml,
)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
if (
object_animation_fingerprint(serialized_slide)
!= source_animation_fingerprint
):
raise RuntimeError(
f'Slide {source_slide} object animations changed during template fill'
)
entries[new_part] = serialized_slide
notes_text = str(item.get("notes") or item.get("speaker_notes") or "")
entries[new_rels], note_entries = _slide_rels_with_notes(
_xml_bytes(slide_rels_root),
slide_number=new_slide_number,
notes_text=notes_text,
notes_master_target=notes_master_target,
)
entries.update(note_entries)
_add_slide_override(content_root, new_part)
if note_entries:
_add_notes_override(content_root, f"ppt/notesSlides/notesSlide{new_slide_number}.xml")
ET.SubElement(
pres_rels_root,
_qn(REL_NS, "Relationship"),
{
"Id": new_rid,
"Type": SLIDE_REL_TYPE,
"Target": f"slides/slide{new_slide_number}.xml",
},
)
ET.SubElement(
sld_id_lst,
_qn(NS["p"], "sldId"),
{"id": str(next_slide_id + offset), _qn(NS["r"], "id"): new_rid},
)
entries["ppt/presentation.xml"] = _xml_bytes(pres_root)
entries["ppt/_rels/presentation.xml.rels"] = _xml_bytes(pres_rels_root)
_prune_unreferenced_parts(entries, content_root)
entries["[Content_Types].xml"] = _xml_bytes(content_root)
if wrote_auto_advance:
try:
set_package_use_timings(entries)
except ValueError as exc:
raise RuntimeError(str(exc)) from exc
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix="template-fill-pptx-",
dir=output_path.parent,
) as temp_dir:
candidate_path = Path(temp_dir) / output_path.name
with zipfile.ZipFile(
candidate_path,
"w",
compression=zipfile.ZIP_DEFLATED,
) as out:
for name, data in entries.items():
out.writestr(name, data)
try:
validate_pptx_transition_package(
candidate_path,
require_use_timings=wrote_auto_advance,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX transition package validation failed: {exc}"
) from exc
try:
validate_pptx_animation_package(
candidate_path,
require_supported_effects=False,
)
except ValueError as exc:
raise RuntimeError(
f"PPTX animation/timing package validation failed: {exc}"
) from exc
candidate_path.replace(output_path)