返回 ppt-master
builder.py
1 """Core PPTX assembly: create_pptx_with_native_svg."""
2
3 from __future__ import annotations
4
5 import hashlib
6 import json
7 import math
8 import mimetypes
9 import os
10 import posixpath
11 import random
12 import re
13 import shutil
14 import stat
15 import subprocess
16 import tempfile
17 import uuid
18 import zipfile
19 from concurrent.futures import ProcessPoolExecutor, as_completed
20 from dataclasses import asdict, dataclass
21 from datetime import datetime, timezone
22 from pathlib import Path
23 from typing import Any
24 from xml.etree import ElementTree as ET
25 from xml.sax.saxutils import escape
26
27 from pptx import Presentation
28 from pptx.util import Emu
29
30 from pptx_transitions import (
31 MorphPairExpectation,
32 NATIVE_TRANSITIONS,
33 create_transition_xml,
34 normalize_transition_effect_request,
35 set_directory_use_timings,
36 validate_generated_transition_xml,
37 validate_pptx_morph_pairs,
38 validate_pptx_transition_package,
39 validate_seconds,
40 )
41 from pptx_animations import (
42 ANIMATION_TIMING_OPTION_FIELDS,
43 animation_seconds_to_milliseconds,
44 create_sequence_timing_xml,
45 normalize_animation_effect,
46 normalize_animation_effect_request,
47 normalize_animation_trigger,
48 pick_animation_effect,
49 validate_generated_animation_xml,
50 validate_pptx_animation_package,
51 )
52 from pptx_opc_validation import (
53 canonical_opc_part_path as _canonical_opc_part_path,
54 resolve_internal_opc_target as _resolve_internal_opc_target,
55 verify_internal_relationships,
56 )
57 from language_tags import normalize_language_tag
58
59 from ..animation_config import (
60 MorphPair,
61 animation_group_effect_entries,
62 resolve_morph_pairs,
63 resolve_slide_animation_config,
64 )
65 from ..drawingml.context import resolve_text_flow
66 from ..drawingml.converter import convert_svg_to_slide_shapes
67 from ..drawingml.theme_colors import (
68 ThemeColorSpec,
69 apply_theme_color_spec,
70 rewrite_chart_accent_colors,
71 )
72 from ..drawingml.theme_fonts import (
73 MasterTextStyleSpec,
74 ThemeFontSpec,
75 apply_master_text_style_spec,
76 apply_theme_font_spec,
77 )
78 from ..drawingml.utils import EMU_PER_PX
79 from ..semantic_markers import (
80 chrome_token_from_markers,
81 page_layout_name_from_svg,
82 )
83 from .dimensions import (
84 CANVAS_FORMATS,
85 resolve_svg_canvas,
86 )
87 from .media import (
88 PNG_RENDERER,
89 get_png_renderer_info, convert_svg_to_png, convert_svg_to_png_cached,
90 )
91 from .notes import (
92 markdown_to_plain_text,
93 create_notes_master_rels_xml,
94 create_notes_master_xml,
95 create_notes_slide_xml,
96 create_notes_slide_rels_xml,
97 )
98 from .narration import (
99 AUDIO_CONTENT_TYPES,
100 AUDIO_REL_TYPE,
101 AUDIO_MARKER_PNG_BYTES,
102 IMAGE_REL_TYPE,
103 MEDIA_REL_TYPE,
104 apply_recorded_timing,
105 inject_narration,
106 next_shape_id,
107 probe_audio_duration,
108 )
109 from .slide_xml import (
110 create_slide_xml_with_svg, create_slide_rels_xml,
111 )
112 from .template_structure import (
113 NativeStructureContract,
114 OOXML_UINT32_MAX,
115 TEMPLATE_PLACEHOLDER_TYPES,
116 TemplateElementSpec,
117 TemplateSlideSpec,
118 TemplateStructureError,
119 flat_structure_metadata_errors,
120 is_proxy_placeholder,
121 match_native_placeholders,
122 parse_preserve_slides,
123 parse_template_slides,
124 template_placeholder_bindings,
125 )
126 from .template_validation import validate_pptx_template_package
127
128 SLIDE_LAYOUT_REL_TYPE = (
129 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
130 )
131 SLIDE_REL_TYPE = (
132 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
133 )
134 SLIDE_MASTER_REL_TYPE = (
135 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
136 )
137 THEME_REL_TYPE = (
138 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
139 )
140 THEME_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.theme+xml"
141 PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
142 DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
143 REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
144 P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
145
146 for _prefix, _uri in (("p", PML_NS), ("a", DML_NS), ("r", REL_NS), ("p14", P14_NS)):
147 try:
148 ET.register_namespace(_prefix, _uri)
149 except (ValueError, AttributeError):
150 pass
151
152
153 @dataclass(frozen=True)
154 class PptxStructureContext:
155 """Resolved base package structure reused when slide XML is regenerated."""
156
157 slide_layout_targets: dict[int, str]
158 slide_master_parts: dict[int, str]
159
160 def slide_layout_target(self, slide_num: int) -> str:
161 """Return the slide layout target for a generated slide."""
162 try:
163 return self.slide_layout_targets[slide_num]
164 except KeyError as exc:
165 raise RuntimeError(
166 f"Missing slide layout relationship for generated slide {slide_num}"
167 ) from exc
168
169 def slide_master_part(self, slide_num: int) -> str:
170 """Return the slide master package part for a generated slide."""
171 try:
172 return self.slide_master_parts[slide_num]
173 except KeyError as exc:
174 raise RuntimeError(
175 f"Missing slide master relationship for generated slide {slide_num}"
176 ) from exc
177
178
179 @dataclass
180 class _TemplateRuntimeSlide:
181 """Parsed slide package state used by explicit Layout structure export."""
182
183 spec: TemplateSlideSpec
184 slide_path: Path
185 rels_path: Path
186 tree: ET.ElementTree
187 root: ET.Element
188 rels: dict[str, dict[str, str]]
189 shapes: dict[str, ET.Element]
190 shape_ids_by_svg_id: dict[str, list[str]]
191
192
193 def _relationship_attrs(elem: ET.Element) -> dict[str, str]:
194 return {key.rsplit("}", 1)[-1]: value for key, value in elem.attrib.items()}
195
196
197 def _resolve_package_target(source_part: str, target: str) -> str:
198 """Resolve a relationship target relative to a package part path."""
199 return posixpath.normpath(posixpath.join(posixpath.dirname(source_part), target))
200
201
202 def _relationships_path_for_part(extract_dir: Path, part_name: str) -> Path:
203 """Return the package relationship sidecar path for a part name."""
204 path = Path(part_name)
205 return extract_dir / path.parent / "_rels" / f"{path.name}.rels"
206
207
208 def _find_relationship_target(
209 rels_path: Path,
210 rel_type: str,
211 ) -> str | None:
212 """Find the first relationship target for a relationship type."""
213 if not rels_path.exists():
214 return None
215 root = ET.parse(rels_path).getroot()
216 for elem in root:
217 attrs = _relationship_attrs(elem)
218 if attrs.get("Type") == rel_type:
219 return attrs.get("Target")
220 return None
221
222
223 def _read_relationships(rels_path: Path) -> dict[str, dict[str, str]]:
224 """Return relationship attributes keyed by rId."""
225 if not rels_path.exists():
226 return {}
227 root = ET.parse(rels_path).getroot()
228 rels: dict[str, dict[str, str]] = {}
229 for elem in root:
230 attrs = _relationship_attrs(elem)
231 rel_id = attrs.get("Id")
232 if rel_id:
233 rels[rel_id] = attrs
234 return rels
235
236
237 def _find_relationship_id(
238 rels_path: Path,
239 rel_type: str,
240 target: str,
241 ) -> str | None:
242 """Find an existing relationship by type and target."""
243 for rel_id, attrs in _read_relationships(rels_path).items():
244 if attrs.get("Type") == rel_type and attrs.get("Target") == target:
245 return rel_id
246 return None
247
248
249 def _read_slide_layout_targets(extract_dir: Path, slide_count: int) -> PptxStructureContext:
250 """Read the actual layout relationship target for every generated slide."""
251 slide_layout_targets: dict[int, str] = {}
252 slide_master_parts: dict[int, str] = {}
253 rels_dir = extract_dir / "ppt" / "slides" / "_rels"
254 for slide_num in range(1, slide_count + 1):
255 rels_path = rels_dir / f"slide{slide_num}.xml.rels"
256 if not rels_path.exists():
257 raise RuntimeError(f"Missing slide relationship file: {rels_path}")
258 target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
259 if not target:
260 raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
261 slide_layout_targets[slide_num] = target
262
263 slide_part = f"ppt/slides/slide{slide_num}.xml"
264 layout_part = _resolve_package_target(slide_part, target)
265 layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
266 master_target = _find_relationship_target(layout_rels_path, SLIDE_MASTER_REL_TYPE)
267 if not master_target:
268 raise RuntimeError(
269 f"Slide {slide_num} layout has no slide master relationship"
270 )
271 slide_master_parts[slide_num] = _resolve_package_target(layout_part, master_target)
272 return PptxStructureContext(
273 slide_layout_targets=slide_layout_targets,
274 slide_master_parts=slide_master_parts,
275 )
276
277
278 _SLIDE_BACKGROUND_RE = re.compile(
279 r"(?P<prefix><p:cSld\b[^>]*>\s*)"
280 r"(?P<bg><p:bg\b.*?</p:bg>)"
281 r"(?P<suffix>\s*<p:spTree\b)",
282 re.DOTALL,
283 )
284
285
286 def _extract_slide_background_xml(slide_xml: str) -> str | None:
287 """Return the slide-level p:bg XML when it directly precedes spTree."""
288 match = _SLIDE_BACKGROUND_RE.search(slide_xml)
289 return match.group("bg") if match else None
290
291
292 def _remove_slide_background_xml(slide_xml: str) -> str:
293 """Remove a promoted slide-level p:bg from cSld."""
294 return _SLIDE_BACKGROUND_RE.sub(r"\g<prefix>\g<suffix>", slide_xml, count=1)
295
296
297 def _put_background_on_part(part_xml: str, background_xml: str) -> str | None:
298 """Replace or insert p:bg before a slide/master/layout spTree.
299
300 Returns None when the part carries a p:bg the canonical pattern cannot
301 replace; inserting there would leave two p:bg children under p:cSld.
302 """
303 match = _SLIDE_BACKGROUND_RE.search(part_xml)
304 if match:
305 return (
306 part_xml[:match.start("bg")]
307 + background_xml
308 + part_xml[match.end("bg"):]
309 )
310 if "<p:bg" in part_xml:
311 return None
312
313 cslide_match = re.search(r"(<p:cSld\b[^>]*>)", part_xml)
314 if not cslide_match:
315 raise RuntimeError("PPTX slide/master/layout part has no p:cSld element")
316 return (
317 part_xml[:cslide_match.end()]
318 + background_xml
319 + part_xml[cslide_match.end():]
320 )
321
322
323 def _dominant_variant(
324 values_by_slide: dict[int, Any],
325 ) -> tuple[Any | None, list[int]]:
326 """Return the most common value and its slides, or None on a tie."""
327 slides_by_value: dict[Any, list[int]] = {}
328 for slide_num, value in sorted(values_by_slide.items()):
329 slides_by_value.setdefault(value, []).append(slide_num)
330 if not slides_by_value:
331 return None, []
332 best_count = max(len(slides) for slides in slides_by_value.values())
333 dominant = [
334 (value, slides)
335 for value, slides in slides_by_value.items()
336 if len(slides) == best_count
337 ]
338 if len(dominant) != 1:
339 return None, []
340 return dominant[0]
341
342
343 def _is_strict_majority(subset_size: int, total: int) -> bool:
344 return subset_size >= 2 and subset_size * 2 > total
345
346
347 def _promote_common_slide_backgrounds_to_masters(
348 extract_dir: Path,
349 structure: PptxStructureContext,
350 slide_count: int,
351 *,
352 verbose: bool = False,
353 ) -> int:
354 """Promote the majority slide background to its shared slide master.
355
356 Every slide in the master group must carry an explicit background —
357 a slide without one would start inheriting the promoted master fill.
358 Minority slides keep their own slide-level background, which always
359 overrides the master fill.
360 """
361 slides_by_master: dict[str, list[int]] = {}
362 for slide_num in range(1, slide_count + 1):
363 master_part = structure.slide_master_part(slide_num)
364 slides_by_master.setdefault(master_part, []).append(slide_num)
365
366 promoted = 0
367 for master_part, slide_nums in slides_by_master.items():
368 slide_backgrounds: dict[int, str] = {}
369 for slide_num in slide_nums:
370 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
371 slide_xml = slide_path.read_text(encoding="utf-8")
372 background_xml = _extract_slide_background_xml(slide_xml)
373 if not background_xml:
374 slide_backgrounds = {}
375 break
376 slide_backgrounds[slide_num] = background_xml
377
378 if not slide_backgrounds:
379 continue
380 background_xml, dominant_slides = _dominant_variant(slide_backgrounds)
381 if background_xml is None:
382 continue
383 if not _is_strict_majority(len(dominant_slides), len(slide_nums)):
384 continue
385
386 master_path = extract_dir / master_part
387 master_xml = master_path.read_text(encoding="utf-8")
388 promoted_master_xml = _put_background_on_part(master_xml, background_xml)
389 if promoted_master_xml is None:
390 continue
391 master_path.write_text(promoted_master_xml, encoding="utf-8")
392
393 for slide_num in dominant_slides:
394 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
395 slide_xml = slide_path.read_text(encoding="utf-8")
396 slide_path.write_text(
397 _remove_slide_background_xml(slide_xml),
398 encoding="utf-8",
399 )
400 promoted += 1
401
402 if verbose and promoted:
403 print(f" Baseline master background: promoted {promoted} slide background(s)")
404 return promoted
405
406
407 _CHROME_TRACE_TOKENS = (
408 "logo",
409 "footer",
410 "header",
411 "watermark",
412 "chrome",
413 "pagenumber",
414 "slidenumber",
415 "pagenum",
416 "slidenum",
417 )
418 _TOP_LEVEL_SHAPE_TAGS = {
419 f"{{{PML_NS}}}sp",
420 f"{{{PML_NS}}}grpSp",
421 f"{{{PML_NS}}}pic",
422 f"{{{PML_NS}}}cxnSp",
423 f"{{{PML_NS}}}graphicFrame",
424 }
425 _FLAT_SYSTEM_PLACEHOLDER_TYPES = frozenset({"dt", "ftr", "sldNum"})
426 _REL_ATTRS = {
427 f"{{{REL_NS}}}embed",
428 f"{{{REL_NS}}}link",
429 f"{{{REL_NS}}}id",
430 }
431
432
433 def _chrome_token_from_svg_id(svg_id: str | None) -> str | None:
434 """Return the baseline chrome token encoded in a source SVG id."""
435 if not svg_id:
436 return None
437 lower = svg_id.lower()
438 compact = re.sub(r"[-_\s]+", "", lower)
439 if compact in _CHROME_TRACE_TOKENS:
440 return compact
441 split_tokens = {token for token in re.split(r"[-_\s]+", lower) if token}
442 for token in _CHROME_TRACE_TOKENS:
443 if token in split_tokens:
444 return token
445 return None
446
447
448 def _trace_chrome_shape_ids(
449 trace: dict[str, Any] | None,
450 ) -> dict[str, list[str]]:
451 """Map chrome token to generated top-level shape ids for one slide."""
452 result: dict[str, list[str]] = {}
453 if not trace:
454 return result
455 for event in trace.get("events", []):
456 if event.get("decision") != "native":
457 continue
458 semantic_role = event.get("data-pptx-role")
459 placeholder = event.get("data-pptx-placeholder")
460 has_explicit_semantics = (
461 semantic_role is not None or placeholder is not None
462 )
463 token = (
464 chrome_token_from_markers(semantic_role, placeholder)
465 if has_explicit_semantics
466 else _chrome_token_from_svg_id(event.get("id"))
467 )
468 shape_id = event.get("shape_id")
469 if token and shape_id is not None:
470 shape_ids = result.setdefault(token, [])
471 normalized_shape_id = str(shape_id)
472 if normalized_shape_id not in shape_ids:
473 shape_ids.append(normalized_shape_id)
474 return result
475
476
477 def _trace_native_shape_ids(
478 trace: dict[str, Any] | None,
479 ) -> dict[str, list[str]]:
480 """Map every traced SVG id to generated top-level shape ids."""
481 result: dict[str, list[str]] = {}
482 if not trace:
483 return result
484 for event in trace.get("events", []):
485 if event.get("decision") != "native":
486 continue
487 svg_id = event.get("id")
488 shape_id = event.get("shape_id")
489 if not svg_id or shape_id is None:
490 continue
491 shape_ids = result.setdefault(str(svg_id), [])
492 normalized = str(shape_id)
493 if normalized not in shape_ids:
494 shape_ids.append(normalized)
495 return result
496
497
498 def _shape_id(elem: ET.Element) -> str | None:
499 for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
500 return cnv.attrib.get("id")
501 return None
502
503
504 def _set_shape_name(elem: ET.Element, name: str) -> None:
505 """Give one top-level shape a deterministic read-back identity."""
506 for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
507 cnv.set("name", name)
508 return
509 raise TemplateStructureError(
510 f"Cannot name structured shape {name!r}: p:cNvPr is missing"
511 )
512
513
514 def _apply_morph_shape_names(
515 extract_dir: Path,
516 pairs: tuple[MorphPair, ...],
517 slide_numbers: dict[str, int],
518 shape_ids: dict[tuple[str, str], int],
519 ) -> dict[int, dict[str, str]]:
520 """Write forced-Morph names after all structure transformations finish."""
521 assignments: dict[int, dict[str, tuple[str, str]]] = {}
522 names_by_slide: dict[int, dict[str, str]] = {}
523 for pair in pairs:
524 for slide_name, group_id in (
525 (pair.source_slide, pair.source_group_id),
526 (pair.destination_slide, pair.destination_group_id),
527 ):
528 slide_number = slide_numbers[slide_name]
529 shape_id = str(shape_ids[(slide_name, group_id)])
530 slide_assignments = assignments.setdefault(slide_number, {})
531 previous = slide_assignments.setdefault(
532 shape_id,
533 (pair.shape_name, group_id),
534 )
535 if previous[0] != pair.shape_name:
536 raise RuntimeError(
537 f'Morph target "{slide_name}/{group_id}" resolves to shape '
538 f'{shape_id} with conflicting names "{previous[0]}" and '
539 f'"{pair.shape_name}"'
540 )
541 slide_names = names_by_slide.setdefault(slide_number, {})
542 previous_group = slide_names.setdefault(pair.shape_name, group_id)
543 if previous_group != group_id:
544 raise RuntimeError(
545 f'Morph name "{pair.shape_name}" maps to multiple objects '
546 f'on slide "{slide_name}"'
547 )
548
549 trace_names: dict[int, dict[str, str]] = {}
550 for slide_number, slide_assignments in sorted(assignments.items()):
551 slide_path = (
552 extract_dir / "ppt" / "slides" / f"slide{slide_number}.xml"
553 )
554 tree = ET.parse(slide_path)
555 root = tree.getroot()
556 top_level_shapes = _top_level_shapes_by_id(root)
557 desired_names = {
558 shape_name
559 for shape_name, _group_id in slide_assignments.values()
560 }
561 for shape_id, shape in top_level_shapes.items():
562 if shape_id in slide_assignments:
563 continue
564 c_nv_pr = next(shape.iter(f"{{{PML_NS}}}cNvPr"), None)
565 existing_name = (
566 c_nv_pr.get("name") if c_nv_pr is not None else None
567 )
568 if existing_name in desired_names:
569 raise RuntimeError(
570 f'Morph name "{existing_name}" already belongs to an '
571 f'unmapped object on slide {slide_number}'
572 )
573
574 for shape_id, (shape_name, group_id) in slide_assignments.items():
575 shape = top_level_shapes.get(shape_id)
576 if shape is None:
577 raise RuntimeError(
578 f'Morph target "{group_id}" no longer resolves to a '
579 f'Slide-local shape on slide {slide_number}'
580 )
581 _set_shape_name(shape, shape_name)
582 trace_names.setdefault(slide_number, {})[group_id] = shape_name
583 _write_xml_tree(slide_path, tree)
584 return trace_names
585
586
587 def _top_level_shape_name_roster(root: ET.Element) -> tuple[str, ...]:
588 """Return the exact visible top-level shape-name sequence for read-back."""
589 sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
590 if sp_tree is None:
591 raise TemplateStructureError("Structured part has no p:cSld/p:spTree")
592 names: list[str] = []
593 for child in sp_tree:
594 if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
595 continue
596 c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
597 name = c_nv_pr.get("name") if c_nv_pr is not None else None
598 if not name:
599 raise TemplateStructureError(
600 "Structured part contains a top-level shape without a name"
601 )
602 names.append(name)
603 return tuple(names)
604
605
606 def _top_level_shapes_by_id(root: ET.Element) -> dict[str, ET.Element]:
607 sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
608 if sp_tree is None:
609 return {}
610 shapes: dict[str, ET.Element] = {}
611 for child in list(sp_tree):
612 if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
613 continue
614 shape_id = _shape_id(child)
615 if shape_id:
616 shapes[shape_id] = child
617 return shapes
618
619
620 def _timing_shape_ids(root: ET.Element) -> set[str]:
621 """Return slide-local shape ids referenced by animation timing."""
622 return {
623 elem.attrib["spid"]
624 for elem in root.findall(f".//{{{PML_NS}}}timing//{{{PML_NS}}}spTgt")
625 if elem.attrib.get("spid")
626 }
627
628
629 def _relationship_ids_in_shape(elem: ET.Element) -> set[str]:
630 rel_ids: set[str] = set()
631 for node in elem.iter():
632 for attr_name, value in node.attrib.items():
633 if attr_name in _REL_ATTRS and value:
634 rel_ids.add(value)
635 return rel_ids
636
637
638 def _shape_relationships_supported(
639 elem: ET.Element,
640 rels: dict[str, dict[str, str]],
641 ) -> bool:
642 """Only image relationships are safe to copy into a slide master here."""
643 for rel_id in _relationship_ids_in_shape(elem):
644 attrs = rels.get(rel_id)
645 if not attrs:
646 return False
647 if attrs.get("TargetMode"):
648 return False
649 if attrs.get("Type") != IMAGE_REL_TYPE:
650 return False
651 return True
652
653
654 def _canonical_shape_xml(
655 elem: ET.Element,
656 rels: dict[str, dict[str, str]],
657 ) -> bytes:
658 """Canonicalize ids and relationship ids for cross-slide equality."""
659 clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
660 for cnv in clone.iter(f"{{{PML_NS}}}cNvPr"):
661 cnv.set("id", "ID")
662 # Generated names include the slide-local shape id (for example,
663 # ``Image 2`` versus ``Image 8``) but do not affect rendering.
664 if "name" in cnv.attrib:
665 cnv.set("name", "NAME")
666 for fld in clone.iter(f"{{{DML_NS}}}fld"):
667 # The literal inside a slide-number field is a per-slide render
668 # cache that PowerPoint recomputes from the slide position.
669 if fld.attrib.get("type") == "slidenum":
670 cached = fld.find(f"{{{DML_NS}}}t")
671 if cached is not None:
672 cached.text = ""
673 for node in clone.iter():
674 for attr_name, value in list(node.attrib.items()):
675 if attr_name not in _REL_ATTRS:
676 continue
677 attrs = rels.get(value, {})
678 node.set(
679 attr_name,
680 f"{attrs.get('Type', '')}|{attrs.get('Target', '')}",
681 )
682 return ET.tostring(clone, encoding="utf-8")
683
684
685 def _ensure_relationship(
686 rels_path: Path,
687 rel_type: str,
688 target: str,
689 ) -> str:
690 existing = _find_relationship_id(rels_path, rel_type, target)
691 if existing:
692 return existing
693 return _append_relationship(rels_path, rel_type, target)
694
695
696 def _copy_shape_relationships_to_part(
697 elem: ET.Element,
698 slide_rels: dict[str, dict[str, str]],
699 target_rels_path: Path,
700 ) -> ET.Element:
701 """Clone a shape and retarget supported relationship ids to another part."""
702 clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
703 for node in clone.iter():
704 for attr_name, value in list(node.attrib.items()):
705 if attr_name not in _REL_ATTRS:
706 continue
707 rel = slide_rels.get(value)
708 if not rel:
709 raise RuntimeError(f"Missing slide relationship for {value}")
710 new_rid = _ensure_relationship(
711 target_rels_path,
712 rel["Type"],
713 rel["Target"],
714 )
715 node.set(attr_name, new_rid)
716 return clone
717
718
719 def _copy_shape_relationships_to_master(
720 elem: ET.Element,
721 slide_rels: dict[str, dict[str, str]],
722 master_rels_path: Path,
723 ) -> ET.Element:
724 """Clone a shape and retarget supported relationship ids to the master."""
725 return _copy_shape_relationships_to_part(elem, slide_rels, master_rels_path)
726
727
728 def _next_master_shape_id(master_xml: str) -> int:
729 ids = [
730 int(match)
731 for match in re.findall(r"<p:cNvPr\b[^>]*\bid=\"(\d+)\"", master_xml)
732 ]
733 return max(ids, default=1) + 1
734
735
736 def _renumber_shape_ids(elem: ET.Element, start_id: int) -> None:
737 next_id = start_id
738 for cnv in elem.iter(f"{{{PML_NS}}}cNvPr"):
739 cnv.set("id", str(next_id))
740 next_id += 1
741
742
743 def _append_shape_to_master(master_path: Path, elem: ET.Element) -> None:
744 master_xml = master_path.read_text(encoding="utf-8")
745 _renumber_shape_ids(elem, _next_master_shape_id(master_xml))
746 shape_xml = ET.tostring(elem, encoding="unicode")
747 if "</p:spTree>" not in master_xml:
748 raise RuntimeError(f"Slide master has no p:spTree: {master_path}")
749 master_path.write_text(
750 master_xml.replace("</p:spTree>", f"{shape_xml}\n</p:spTree>", 1),
751 encoding="utf-8",
752 )
753
754
755 def _append_shape_to_part(part_path: Path, elem: ET.Element) -> None:
756 """Append a top-level shape to a master/layout spTree with fresh ids."""
757 tree = ET.parse(part_path)
758 root = tree.getroot()
759 sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
760 if sp_tree is None:
761 raise RuntimeError(f"PPTX part has no p:spTree: {part_path}")
762 existing_ids = [
763 int(cnv.attrib["id"])
764 for cnv in root.iter(f"{{{PML_NS}}}cNvPr")
765 if cnv.attrib.get("id", "").isdigit()
766 ]
767 clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
768 _renumber_shape_ids(clone, max(existing_ids, default=1) + 1)
769 sp_tree.append(clone)
770 _write_xml_tree(part_path, tree)
771
772
773 def _write_xml_tree(path: Path, tree: ET.ElementTree) -> None:
774 tree.write(path, encoding="utf-8", xml_declaration=True)
775
776
777 COVER_LAYOUT_NAME = "Cover"
778
779
780 def _next_layout_part_number(extract_dir: Path) -> int:
781 layouts_dir = extract_dir / "ppt" / "slideLayouts"
782 numbers = [
783 int(match.group(1))
784 for path in layouts_dir.glob("slideLayout*.xml")
785 if (match := re.fullmatch(r"slideLayout(\d+)\.xml", path.name))
786 ]
787 return max(numbers, default=0) + 1
788
789
790 def _next_slide_layout_id(extract_dir: Path) -> int:
791 """Return a package-wide unused id for a new sldLayoutId entry."""
792 ids: list[int] = []
793 masters_dir = extract_dir / "ppt" / "slideMasters"
794 for master_path in sorted(masters_dir.glob("slideMaster*.xml")):
795 master_root = ET.parse(master_path).getroot()
796 ids.extend(
797 int(entry.attrib["id"])
798 for entry in master_root.findall(
799 f"{{{PML_NS}}}sldLayoutIdLst/{{{PML_NS}}}sldLayoutId"
800 )
801 if entry.attrib.get("id", "").isdigit()
802 )
803 presentation_path = extract_dir / "ppt" / "presentation.xml"
804 if presentation_path.exists():
805 ids.extend(
806 int(value)
807 for value in re.findall(
808 r'\bid="(\d{9,})"', presentation_path.read_text(encoding="utf-8")
809 )
810 )
811 next_id = max([*ids, 2147483648]) + 1
812 if next_id > OOXML_UINT32_MAX:
813 raise TemplateStructureError(
814 "Cannot register another Slide Layout because the OOXML UInt32 "
815 "identifier range is exhausted"
816 )
817 return next_id
818
819
820 def _create_cover_layout(extract_dir: Path, master_part: str, base_layout_part: str) -> str:
821 """Clone a layout into a Cover layout that hides master shapes.
822
823 Returns the new layout target relative to slide parts.
824 """
825 base_layout_path = extract_dir / base_layout_part
826 layout_xml = base_layout_path.read_text(encoding="utf-8")
827
828 root_match = re.search(r"<p:sldLayout\b[^>]*>", layout_xml)
829 if not root_match:
830 raise RuntimeError(f"Slide layout has no p:sldLayout root: {base_layout_part}")
831 root_tag = root_match.group(0)
832 if "showMasterSp=" in root_tag:
833 new_root_tag = re.sub(r'showMasterSp="[^"]*"', 'showMasterSp="0"', root_tag)
834 else:
835 new_root_tag = root_tag[:-1] + ' showMasterSp="0">'
836 layout_xml = layout_xml.replace(root_tag, new_root_tag, 1)
837 layout_xml = re.sub(
838 r"(<p:cSld\b[^>]*?)\s+name=\"[^\"]*\"",
839 rf'\g<1> name="{COVER_LAYOUT_NAME}"',
840 layout_xml,
841 count=1,
842 )
843
844 layout_num = _next_layout_part_number(extract_dir)
845 new_layout_part = f"ppt/slideLayouts/slideLayout{layout_num}.xml"
846 new_layout_path = extract_dir / new_layout_part
847 new_layout_path.write_text(layout_xml, encoding="utf-8")
848
849 base_rels_path = _relationships_path_for_part(extract_dir, base_layout_part)
850 new_rels_path = _relationships_path_for_part(extract_dir, new_layout_part)
851 new_rels_path.parent.mkdir(exist_ok=True)
852 new_rels_path.write_text(
853 base_rels_path.read_text(encoding="utf-8"), encoding="utf-8"
854 )
855
856 master_path = extract_dir / master_part
857 master_rels_path = _relationships_path_for_part(extract_dir, master_part)
858 layout_target = posixpath.relpath(
859 new_layout_part, posixpath.dirname(master_part)
860 )
861 rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target)
862
863 master_xml = master_path.read_text(encoding="utf-8")
864 layout_id = _next_slide_layout_id(extract_dir)
865 entry = f'<p:sldLayoutId id="{layout_id}" r:id="{rel_id}"/>'
866 if "</p:sldLayoutIdLst>" not in master_xml:
867 raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}")
868 master_path.write_text(
869 master_xml.replace("</p:sldLayoutIdLst>", f"{entry}</p:sldLayoutIdLst>", 1),
870 encoding="utf-8",
871 )
872
873 content_types_path = extract_dir / "[Content_Types].xml"
874 content_types_path.write_text(
875 _add_content_type_override(
876 content_types_path.read_text(encoding="utf-8"),
877 new_layout_part,
878 "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
879 ),
880 encoding="utf-8",
881 )
882 return posixpath.relpath(new_layout_part, "ppt/slides")
883
884
885 def _create_custom_layout(
886 extract_dir: Path,
887 master_part: str,
888 base_layout_part: str,
889 layout_name: str,
890 *,
891 show_master_shapes: bool = True,
892 ) -> tuple[str, str]:
893 """Clone a clean custom layout and register it under its slide master.
894
895 Returns ``(slide_relationship_target, package_part)``.
896 """
897 base_layout_path = extract_dir / base_layout_part
898 tree = ET.parse(base_layout_path)
899 root = tree.getroot()
900 _reseed_p14_creation_id(root)
901 root.set("type", "cust")
902 root.set("preserve", "1")
903 root.set("showMasterSp", "1" if show_master_shapes else "0")
904
905 c_sld = root.find(f"{{{PML_NS}}}cSld")
906 if c_sld is None:
907 raise RuntimeError(f"Slide layout has no p:cSld: {base_layout_part}")
908 c_sld.set("name", layout_name)
909 sp_tree = c_sld.find(f"{{{PML_NS}}}spTree")
910 if sp_tree is None:
911 raise RuntimeError(f"Slide layout has no p:spTree: {base_layout_part}")
912 for child in list(sp_tree):
913 if child.tag in _TOP_LEVEL_SHAPE_TAGS:
914 sp_tree.remove(child)
915
916 layout_num = _next_layout_part_number(extract_dir)
917 new_layout_part = f"ppt/slideLayouts/slideLayout{layout_num}.xml"
918 new_layout_path = extract_dir / new_layout_part
919 _write_xml_tree(new_layout_path, tree)
920
921 base_rels_path = _relationships_path_for_part(extract_dir, base_layout_part)
922 new_rels_path = _relationships_path_for_part(extract_dir, new_layout_part)
923 new_rels_path.parent.mkdir(exist_ok=True)
924 new_rels_path.write_text(
925 base_rels_path.read_text(encoding="utf-8"),
926 encoding="utf-8",
927 )
928 master_target = posixpath.relpath(
929 master_part,
930 posixpath.dirname(new_layout_part),
931 )
932 rels_content = new_rels_path.read_text(encoding="utf-8")
933 master_rel_ids = [
934 rel_id
935 for rel_id, attrs in _read_relationships(new_rels_path).items()
936 if attrs.get("Type") == SLIDE_MASTER_REL_TYPE
937 ]
938 if len(master_rel_ids) != 1:
939 raise RuntimeError(
940 f"Cloned slide layout must have one Master relationship: {new_layout_part}"
941 )
942 master_rel_id = master_rel_ids[0]
943 master_rel_pattern = re.compile(
944 rf'(<Relationship\b[^>]*\bId="{re.escape(master_rel_id)}"'
945 rf'[^>]*\bTarget=")[^"]*(")'
946 )
947 rels_content, replaced = master_rel_pattern.subn(
948 rf"\g<1>{master_target}\g<2>",
949 rels_content,
950 count=1,
951 )
952 if replaced != 1:
953 raise RuntimeError(
954 f"Could not retarget cloned Layout to Master {master_part}"
955 )
956 new_rels_path.write_text(rels_content, encoding="utf-8")
957
958 master_path = extract_dir / master_part
959 master_rels_path = _relationships_path_for_part(extract_dir, master_part)
960 layout_target = posixpath.relpath(new_layout_part, posixpath.dirname(master_part))
961 rel_id = _append_relationship(master_rels_path, SLIDE_LAYOUT_REL_TYPE, layout_target)
962 master_tree = ET.parse(master_path)
963 master_root = master_tree.getroot()
964 layout_list = master_root.find(f"{{{PML_NS}}}sldLayoutIdLst")
965 if layout_list is None:
966 raise RuntimeError(f"Slide master has no sldLayoutIdLst: {master_part}")
967 layout_id = _next_slide_layout_id(extract_dir)
968 ET.SubElement(
969 layout_list,
970 f"{{{PML_NS}}}sldLayoutId",
971 {"id": str(layout_id), f"{{{REL_NS}}}id": rel_id},
972 )
973 _write_xml_tree(master_path, master_tree)
974
975 content_types_path = extract_dir / "[Content_Types].xml"
976 content_types_path.write_text(
977 _add_content_type_override(
978 content_types_path.read_text(encoding="utf-8"),
979 new_layout_part,
980 "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml",
981 ),
982 encoding="utf-8",
983 )
984 return posixpath.relpath(new_layout_part, "ppt/slides"), new_layout_part
985
986
987 def _set_master_picker_name(master_path: Path, master_name: str) -> None:
988 """Set the visible PowerPoint Master name on its common slide data."""
989 tree = ET.parse(master_path)
990 c_sld = tree.getroot().find(f"{{{PML_NS}}}cSld")
991 if c_sld is None:
992 raise RuntimeError(f"Slide master has no p:cSld: {master_path}")
993 c_sld.set("name", master_name)
994 _write_xml_tree(master_path, tree)
995
996
997 def _reseed_p14_creation_id(root: ET.Element) -> None:
998 """Give a cloned Slide/Master/Layout part a fresh PowerPoint creation id."""
999 c_sld = root.find(f"{{{PML_NS}}}cSld")
1000 if c_sld is None:
1001 return
1002 creation_ids = c_sld.findall(
1003 f"{{{PML_NS}}}extLst/{{{PML_NS}}}ext/"
1004 f"{{{P14_NS}}}creationId"
1005 )
1006 for creation_id in creation_ids:
1007 value = 0
1008 while value == 0:
1009 value = uuid.uuid4().int & OOXML_UINT32_MAX
1010 creation_id.set("val", str(value))
1011
1012
1013 def _next_master_part_number(extract_dir: Path) -> int:
1014 numbers = [
1015 int(match.group(1))
1016 for path in (extract_dir / "ppt" / "slideMasters").glob("slideMaster*.xml")
1017 if (match := re.fullmatch(r"slideMaster(\d+)\.xml", path.name))
1018 ]
1019 return max(numbers, default=0) + 1
1020
1021
1022 def _next_theme_part_number(extract_dir: Path) -> int:
1023 numbers = [
1024 int(match.group(1))
1025 for path in (extract_dir / "ppt" / "theme").glob("theme*.xml")
1026 if (match := re.fullmatch(r"theme(\d+)\.xml", path.name))
1027 ]
1028 return max(numbers, default=0) + 1
1029
1030
1031 def _clone_master_theme(
1032 extract_dir: Path,
1033 source_master_part: str,
1034 master_part: str,
1035 master_name: str,
1036 ) -> str:
1037 """Give a cloned Slide Master its own Theme package part."""
1038 source_master_rels = _relationships_path_for_part(
1039 extract_dir,
1040 source_master_part,
1041 )
1042 theme_targets = [
1043 attrs["Target"]
1044 for attrs in _read_relationships(source_master_rels).values()
1045 if attrs.get("Type") == THEME_REL_TYPE and attrs.get("Target")
1046 ]
1047 if len(theme_targets) != 1:
1048 raise RuntimeError(
1049 "Source Slide Master must have one Theme relationship: "
1050 f"{source_master_part}"
1051 )
1052 source_theme_part = _resolve_package_target(
1053 source_master_part,
1054 theme_targets[0],
1055 )
1056 source_theme_path = extract_dir / source_theme_part
1057 if not source_theme_path.exists():
1058 raise RuntimeError(
1059 f"Slide Master Theme part is missing: {source_theme_part}"
1060 )
1061
1062 theme_num = _next_theme_part_number(extract_dir)
1063 theme_part = f"ppt/theme/theme{theme_num}.xml"
1064 theme_path = extract_dir / theme_part
1065 shutil.copyfile(source_theme_path, theme_path)
1066 theme_tree = ET.parse(theme_path)
1067 theme_tree.getroot().set("name", f"{master_name} Theme")
1068 _write_xml_tree(theme_path, theme_tree)
1069
1070 source_theme_rels = _relationships_path_for_part(
1071 extract_dir,
1072 source_theme_part,
1073 )
1074 if source_theme_rels.exists():
1075 theme_rels = _relationships_path_for_part(extract_dir, theme_part)
1076 theme_rels.parent.mkdir(parents=True, exist_ok=True)
1077 shutil.copyfile(source_theme_rels, theme_rels)
1078
1079 master_rels = _relationships_path_for_part(extract_dir, master_part)
1080 theme_relationships = [
1081 (rel_id, attrs)
1082 for rel_id, attrs in _read_relationships(master_rels).items()
1083 if attrs.get("Type") == THEME_REL_TYPE
1084 ]
1085 if len(theme_relationships) != 1:
1086 raise RuntimeError(
1087 f"Cloned Slide Master must have one Theme relationship: {master_part}"
1088 )
1089 theme_rel_id, _attrs = theme_relationships[0]
1090 theme_target = posixpath.relpath(theme_part, posixpath.dirname(master_part))
1091 rels_content = master_rels.read_text(encoding="utf-8")
1092 theme_rel_pattern = re.compile(
1093 rf'(<Relationship\b[^>]*\bId="{re.escape(theme_rel_id)}"'
1094 rf'[^>]*\bTarget=")[^"]*(")'
1095 )
1096 rels_content, replaced = theme_rel_pattern.subn(
1097 rf"\g<1>{theme_target}\g<2>",
1098 rels_content,
1099 count=1,
1100 )
1101 if replaced != 1:
1102 raise RuntimeError(
1103 f"Could not retarget cloned Slide Master Theme: {master_part}"
1104 )
1105 master_rels.write_text(rels_content, encoding="utf-8")
1106
1107 content_types_path = extract_dir / "[Content_Types].xml"
1108 content_types_path.write_text(
1109 _add_content_type_override(
1110 content_types_path.read_text(encoding="utf-8"),
1111 theme_part,
1112 THEME_CONTENT_TYPE,
1113 ),
1114 encoding="utf-8",
1115 )
1116 return theme_part
1117
1118
1119 def _clone_structured_master(
1120 extract_dir: Path,
1121 source_master_part: str,
1122 master_name: str,
1123 ) -> str:
1124 """Clone a clean Master part and register it with the Presentation."""
1125 master_num = _next_master_part_number(extract_dir)
1126 master_part = f"ppt/slideMasters/slideMaster{master_num}.xml"
1127 master_path = extract_dir / master_part
1128 source_master_path = extract_dir / source_master_part
1129 shutil.copyfile(source_master_path, master_path)
1130
1131 tree = ET.parse(master_path)
1132 root = tree.getroot()
1133 _reseed_p14_creation_id(root)
1134 c_sld = root.find(f"{{{PML_NS}}}cSld")
1135 if c_sld is None:
1136 raise RuntimeError(f"Slide master has no p:cSld: {source_master_part}")
1137 c_sld.set("name", master_name)
1138 layout_list = root.find(f"{{{PML_NS}}}sldLayoutIdLst")
1139 if layout_list is None:
1140 raise RuntimeError(
1141 f"Slide master has no p:sldLayoutIdLst: {source_master_part}"
1142 )
1143 for entry in list(layout_list):
1144 layout_list.remove(entry)
1145 _write_xml_tree(master_path, tree)
1146
1147 source_rels = _relationships_path_for_part(extract_dir, source_master_part)
1148 master_rels = _relationships_path_for_part(extract_dir, master_part)
1149 master_rels.parent.mkdir(parents=True, exist_ok=True)
1150 shutil.copyfile(source_rels, master_rels)
1151 for rel_id, attrs in tuple(_read_relationships(master_rels).items()):
1152 if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE:
1153 _remove_relationship(master_rels, rel_id)
1154 _clone_master_theme(
1155 extract_dir,
1156 source_master_part,
1157 master_part,
1158 master_name,
1159 )
1160
1161 presentation_rels = extract_dir / "ppt" / "_rels" / "presentation.xml.rels"
1162 relationship_target = posixpath.relpath(master_part, "ppt")
1163 relationship_id = _append_relationship(
1164 presentation_rels,
1165 SLIDE_MASTER_REL_TYPE,
1166 relationship_target,
1167 )
1168 presentation_path = extract_dir / "ppt" / "presentation.xml"
1169 presentation_xml = presentation_path.read_text(encoding="utf-8")
1170 master_ids = [
1171 int(value)
1172 for value in re.findall(r'<p:sldMasterId\b[^>]*\bid="(\d+)"', presentation_xml)
1173 ]
1174 master_id = max(master_ids, default=(1 << 31) - 1) + 1
1175 if master_id > OOXML_UINT32_MAX:
1176 raise TemplateStructureError("Presentation Master id exceeds OOXML UInt32")
1177 entry = f'<p:sldMasterId id="{master_id}" r:id="{relationship_id}"/>'
1178 if "</p:sldMasterIdLst>" not in presentation_xml:
1179 raise RuntimeError("presentation.xml has no p:sldMasterIdLst")
1180 presentation_path.write_text(
1181 presentation_xml.replace(
1182 "</p:sldMasterIdLst>",
1183 f"{entry}</p:sldMasterIdLst>",
1184 1,
1185 ),
1186 encoding="utf-8",
1187 )
1188
1189 content_types_path = extract_dir / "[Content_Types].xml"
1190 content_types_path.write_text(
1191 _add_content_type_override(
1192 content_types_path.read_text(encoding="utf-8"),
1193 master_part,
1194 "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml",
1195 ),
1196 encoding="utf-8",
1197 )
1198 return master_part
1199
1200
1201 def _assign_structured_masters(
1202 extract_dir: Path,
1203 structure: PptxStructureContext,
1204 specs: list[TemplateSlideSpec],
1205 ) -> dict[str, str]:
1206 """Create one registered package Master for each explicit SVG Master key."""
1207 if not specs:
1208 raise TemplateStructureError("Structured export requires at least one slide")
1209 source_master = structure.slide_master_part(specs[0].slide_num)
1210 masters: dict[str, str] = {}
1211 for spec in specs:
1212 existing_part = masters.get(spec.master_key)
1213 if existing_part is None:
1214 if not masters:
1215 existing_part = source_master
1216 _set_master_picker_name(
1217 extract_dir / existing_part,
1218 spec.master_name,
1219 )
1220 else:
1221 existing_part = _clone_structured_master(
1222 extract_dir,
1223 source_master,
1224 spec.master_name,
1225 )
1226 masters[spec.master_key] = existing_part
1227 structure.slide_master_parts[spec.slide_num] = existing_part
1228 return masters
1229
1230
1231 def _clear_master_placeholder_shapes(master_path: Path) -> None:
1232 """Remove base-package placeholders before installing structured content."""
1233 tree = ET.parse(master_path)
1234 root = tree.getroot()
1235 sp_tree = root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
1236 if sp_tree is None:
1237 raise RuntimeError(f"Slide master has no p:spTree: {master_path}")
1238 for child in list(sp_tree):
1239 if child.find(f".//{{{PML_NS}}}ph") is not None:
1240 sp_tree.remove(child)
1241 _write_xml_tree(master_path, tree)
1242
1243
1244 def _set_slide_layout_target(rels_path: Path, target: str) -> None:
1245 """Point a slide's layout relationship at a different layout part."""
1246 content = rels_path.read_text(encoding="utf-8")
1247 rel_id = None
1248 for existing_id, attrs in _read_relationships(rels_path).items():
1249 if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE:
1250 rel_id = existing_id
1251 break
1252 if rel_id is None:
1253 raise RuntimeError(f"No slide layout relationship in {rels_path}")
1254 pattern = re.compile(
1255 rf'(<Relationship\b[^>]*\bId="{re.escape(rel_id)}"[^>]*\bTarget=")[^"]*(")'
1256 )
1257 new_content, replaced = pattern.subn(rf"\g<1>{target}\g<2>", content, count=1)
1258 if not replaced:
1259 raise RuntimeError(f"Could not retarget layout relationship in {rels_path}")
1260 rels_path.write_text(new_content, encoding="utf-8")
1261
1262
1263 _BASELINE_LAYOUT_ROLE_TOKENS = (
1264 ("Cover", frozenset({"cover", "frontcover"}), ("封面",)),
1265 (
1266 "Agenda",
1267 frozenset({"agenda", "contents", "outline", "toc"}),
1268 ("目录", "议程"),
1269 ),
1270 (
1271 "Section",
1272 frozenset({"chapter", "divider", "section", "transition"}),
1273 ("章节", "过渡页"),
1274 ),
1275 (
1276 "Closing",
1277 frozenset({"closing", "end", "ending", "qa", "thankyou", "thanks"}),
1278 ("封底", "结束", "结尾", "结语", "致谢", "谢谢"),
1279 ),
1280 )
1281
1282
1283 def _baseline_layout_role(svg_path: Path) -> str:
1284 """Use explicit page semantics, then fall back to conservative filename roles."""
1285 semantic_role = page_layout_name_from_svg(svg_path)
1286 if semantic_role:
1287 return semantic_role
1288 stem = svg_path.stem.casefold()
1289 tokens = {
1290 token
1291 for token in re.split(r"[^0-9a-z]+", stem)
1292 if token and not token.isdigit()
1293 }
1294 for role, english_tokens, cjk_tokens in _BASELINE_LAYOUT_ROLE_TOKENS:
1295 if tokens.intersection(english_tokens) or any(
1296 token in stem for token in cjk_tokens
1297 ):
1298 return role
1299 return "Content"
1300
1301
1302 def _layout_identity(layout_path: Path) -> tuple[str, bool]:
1303 """Return a layout's picker name and master-shape visibility."""
1304 root = ET.parse(layout_path).getroot()
1305 c_sld = root.find(f"{{{PML_NS}}}cSld")
1306 name = c_sld.attrib.get("name", "") if c_sld is not None else ""
1307 return name, root.attrib.get("showMasterSp", "1") != "0"
1308
1309
1310 def _shared_explicit_slide_background(
1311 extract_dir: Path,
1312 slide_nums: list[int],
1313 ) -> str | None:
1314 """Return one exact background only when every family slide carries it."""
1315 backgrounds: list[str] = []
1316 for slide_num in slide_nums:
1317 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
1318 background = _extract_slide_background_xml(
1319 slide_path.read_text(encoding="utf-8")
1320 )
1321 if background is None:
1322 return None
1323 backgrounds.append(background)
1324 if not backgrounds or len(set(backgrounds)) != 1:
1325 return None
1326 return backgrounds[0]
1327
1328
1329 def _extract_baseline_layout_families(
1330 extract_dir: Path,
1331 structure: PptxStructureContext,
1332 svg_files: list[Path],
1333 *,
1334 verbose: bool = False,
1335 ) -> int:
1336 """Build conservative post-generation layout families for a free SVG deck."""
1337 families: dict[tuple[str, str, bool], list[int]] = {}
1338 base_layouts: dict[tuple[str, str, bool], str] = {}
1339 for slide_num, svg_path in enumerate(svg_files, 1):
1340 rels_path = (
1341 extract_dir
1342 / "ppt"
1343 / "slides"
1344 / "_rels"
1345 / f"slide{slide_num}.xml.rels"
1346 )
1347 layout_target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
1348 if not layout_target:
1349 raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
1350 layout_part = _resolve_package_target(
1351 f"ppt/slides/slide{slide_num}.xml", layout_target
1352 )
1353 layout_name, show_master_shapes = _layout_identity(extract_dir / layout_part)
1354 role = _baseline_layout_role(svg_path)
1355 if role == "Content" and layout_name == COVER_LAYOUT_NAME:
1356 role = COVER_LAYOUT_NAME
1357 key = (
1358 structure.slide_master_part(slide_num),
1359 role,
1360 show_master_shapes,
1361 )
1362 families.setdefault(key, []).append(slide_num)
1363 base_layouts.setdefault(key, layout_part)
1364
1365 created = 0
1366 lifted_backgrounds = 0
1367 role_counts: dict[tuple[str, str], int] = {}
1368 for key, slide_nums in families.items():
1369 master_part, role, show_master_shapes = key
1370 role_key = (master_part, role)
1371 role_counts[role_key] = role_counts.get(role_key, 0) + 1
1372 variant = role_counts[role_key]
1373 layout_name = role if variant == 1 else f"{role} {variant}"
1374 layout_target, layout_part = _create_custom_layout(
1375 extract_dir,
1376 master_part,
1377 base_layouts[key],
1378 layout_name,
1379 show_master_shapes=show_master_shapes,
1380 )
1381
1382 background_xml = _shared_explicit_slide_background(
1383 extract_dir, slide_nums
1384 )
1385 if background_xml is not None:
1386 layout_path = extract_dir / layout_part
1387 layout_xml = layout_path.read_text(encoding="utf-8")
1388 updated_layout_xml = _put_background_on_part(layout_xml, background_xml)
1389 if updated_layout_xml is not None:
1390 layout_path.write_text(updated_layout_xml, encoding="utf-8")
1391 for slide_num in slide_nums:
1392 slide_path = (
1393 extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
1394 )
1395 slide_path.write_text(
1396 _remove_slide_background_xml(
1397 slide_path.read_text(encoding="utf-8")
1398 ),
1399 encoding="utf-8",
1400 )
1401 lifted_backgrounds += len(slide_nums)
1402
1403 for slide_num in slide_nums:
1404 rels_path = (
1405 extract_dir
1406 / "ppt"
1407 / "slides"
1408 / "_rels"
1409 / f"slide{slide_num}.xml.rels"
1410 )
1411 _set_slide_layout_target(rels_path, layout_target)
1412 created += 1
1413
1414 if verbose and created:
1415 print(
1416 " Baseline layout families: "
1417 f"created {created} reusable layout(s), "
1418 f"lifted {lifted_backgrounds} slide background(s)"
1419 )
1420 return created
1421
1422
1423 def _promote_common_chrome_shapes_to_layouts(
1424 extract_dir: Path,
1425 slide_count: int,
1426 conversion_traces: list[dict[str, Any]] | None,
1427 *,
1428 verbose: bool = False,
1429 ) -> int:
1430 """Promote exact leading chrome shared by every slide in one layout."""
1431 if not conversion_traces:
1432 return 0
1433 trace_by_slide = {
1434 int(trace.get("slide_num", 0)): trace
1435 for trace in conversion_traces
1436 if trace.get("slide_num") is not None
1437 }
1438 if len(trace_by_slide) < slide_count:
1439 return 0
1440
1441 slides_by_layout: dict[str, list[int]] = {}
1442 for slide_num in range(1, slide_count + 1):
1443 rels_path = (
1444 extract_dir
1445 / "ppt"
1446 / "slides"
1447 / "_rels"
1448 / f"slide{slide_num}.xml.rels"
1449 )
1450 layout_target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
1451 if not layout_target:
1452 raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
1453 layout_part = _resolve_package_target(
1454 f"ppt/slides/slide{slide_num}.xml",
1455 layout_target,
1456 )
1457 slides_by_layout.setdefault(layout_part, []).append(slide_num)
1458
1459 promoted = 0
1460 promoted_roles = 0
1461 for layout_part, slide_nums in slides_by_layout.items():
1462 if len(slide_nums) < 2:
1463 continue
1464 slide_state: dict[int, dict[str, Any]] = {}
1465 for slide_num in slide_nums:
1466 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
1467 rels_path = (
1468 extract_dir
1469 / "ppt"
1470 / "slides"
1471 / "_rels"
1472 / f"slide{slide_num}.xml.rels"
1473 )
1474 tree = ET.parse(slide_path)
1475 root = tree.getroot()
1476 slide_state[slide_num] = {
1477 "path": slide_path,
1478 "rels": _read_relationships(rels_path),
1479 "root": root,
1480 "shapes": _top_level_shapes_by_id(root),
1481 "timing_shape_ids": _timing_shape_ids(root),
1482 "tokens": _trace_chrome_shape_ids(trace_by_slide.get(slide_num)),
1483 "tree": tree,
1484 }
1485
1486 common_tokens = set.intersection(*(
1487 set(state["tokens"])
1488 for state in slide_state.values()
1489 ))
1490 common_tokens.difference_update(_PAGE_NUMBER_TOKENS)
1491 candidates: dict[str, dict[int, str]] = {}
1492 for token in sorted(common_tokens):
1493 shape_ids_by_slide: dict[int, str] = {}
1494 canonical_shapes: set[bytes] = set()
1495 for slide_num in slide_nums:
1496 state = slide_state[slide_num]
1497 shape_ids = state["tokens"].get(token, [])
1498 if len(shape_ids) != 1:
1499 break
1500 shape_id = shape_ids[0]
1501 shape = state["shapes"].get(shape_id)
1502 if shape is None or shape_id in state["timing_shape_ids"]:
1503 break
1504 if not _shape_relationships_supported(shape, state["rels"]):
1505 break
1506 shape_ids_by_slide[slide_num] = shape_id
1507 canonical_shapes.add(_canonical_shape_xml(shape, state["rels"]))
1508 if len(shape_ids_by_slide) == len(slide_nums) and len(canonical_shapes) == 1:
1509 candidates[token] = shape_ids_by_slide
1510
1511 if not candidates:
1512 continue
1513
1514 # Layout shapes render behind slide-local shapes. Keep visual z-order by
1515 # accepting only the identical leading prefix shared by every family page.
1516 token_by_shape_id = {
1517 slide_num: {
1518 shape_ids[slide_num]: token
1519 for token, shape_ids in candidates.items()
1520 }
1521 for slide_num in slide_nums
1522 }
1523 leading_orders: list[list[str]] = []
1524 for slide_num in slide_nums:
1525 order: list[str] = []
1526 for shape_id in slide_state[slide_num]["shapes"]:
1527 token = token_by_shape_id[slide_num].get(shape_id)
1528 if token is None:
1529 break
1530 order.append(token)
1531 leading_orders.append(order)
1532 safe_tokens = list(leading_orders[0])
1533 for order in leading_orders[1:]:
1534 common_length = 0
1535 for expected, actual in zip(safe_tokens, order):
1536 if expected != actual:
1537 break
1538 common_length += 1
1539 safe_tokens = safe_tokens[:common_length]
1540 if not safe_tokens:
1541 break
1542 if not safe_tokens:
1543 continue
1544
1545 layout_path = extract_dir / layout_part
1546 layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
1547 for token in safe_tokens:
1548 shape_ids_by_slide = candidates[token]
1549 first_slide = slide_nums[0]
1550 first_state = slide_state[first_slide]
1551 shape = first_state["shapes"][shape_ids_by_slide[first_slide]]
1552 layout_shape = _copy_shape_relationships_to_part(
1553 shape,
1554 first_state["rels"],
1555 layout_rels_path,
1556 )
1557 _append_shape_to_part(layout_path, layout_shape)
1558 promoted_roles += 1
1559 for slide_num, shape_id in shape_ids_by_slide.items():
1560 state = slide_state[slide_num]
1561 shape_to_remove = state["shapes"].get(shape_id)
1562 sp_tree = state["root"].find(
1563 f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree"
1564 )
1565 if sp_tree is not None and shape_to_remove is not None:
1566 sp_tree.remove(shape_to_remove)
1567 promoted += 1
1568
1569 for slide_num in slide_nums:
1570 state = slide_state[slide_num]
1571 _write_xml_tree(state["path"], state["tree"])
1572
1573 if verbose and promoted:
1574 print(
1575 " Baseline layout chrome: "
1576 f"promoted {promoted} slide shape(s) across "
1577 f"{promoted_roles} shared object(s)"
1578 )
1579 return promoted
1580
1581
1582 _TEMPLATE_PLACEHOLDER_PROMPTS = {
1583 "title": "Click to add title",
1584 "subtitle": "Click to add subtitle",
1585 "body": "Click to add text",
1586 "picture": "Click to add picture",
1587 "chart": "Click to add chart",
1588 "table": "Click to add table",
1589 "object": "Click to add content",
1590 "media": "Click to add media",
1591 "date": "Date",
1592 "footer": "Footer",
1593 }
1594
1595 _PARAGRAPH_BULLET_CHOICE_TAGS = {
1596 f"{{{DML_NS}}}buNone",
1597 f"{{{DML_NS}}}buAutoNum",
1598 f"{{{DML_NS}}}buChar",
1599 f"{{{DML_NS}}}buBlip",
1600 }
1601 _PARAGRAPH_PROPERTIES_TRAILING_TAGS = {
1602 f"{{{DML_NS}}}tabLst",
1603 f"{{{DML_NS}}}defRPr",
1604 f"{{{DML_NS}}}extLst",
1605 }
1606
1607
1608 def _template_runtime_slides(
1609 extract_dir: Path,
1610 specs: list[TemplateSlideSpec],
1611 conversion_traces: list[dict[str, Any]] | None,
1612 ) -> list[_TemplateRuntimeSlide]:
1613 """Load slide XML state and join it with SVG-to-shape trace ids."""
1614 if not conversion_traces:
1615 raise TemplateStructureError(
1616 "Explicit Layout export requires native conversion traces for every slide"
1617 )
1618 trace_by_slide = {
1619 int(trace.get("slide_num", 0)): trace
1620 for trace in conversion_traces
1621 if trace.get("slide_num") is not None
1622 }
1623 states: list[_TemplateRuntimeSlide] = []
1624 for spec in specs:
1625 trace = trace_by_slide.get(spec.slide_num)
1626 if trace is None:
1627 raise TemplateStructureError(
1628 f"{spec.svg_path.name}: missing native conversion trace"
1629 )
1630 slide_path = extract_dir / "ppt" / "slides" / f"slide{spec.slide_num}.xml"
1631 rels_path = (
1632 extract_dir
1633 / "ppt"
1634 / "slides"
1635 / "_rels"
1636 / f"slide{spec.slide_num}.xml.rels"
1637 )
1638 tree = ET.parse(slide_path)
1639 root = tree.getroot()
1640 states.append(_TemplateRuntimeSlide(
1641 spec=spec,
1642 slide_path=slide_path,
1643 rels_path=rels_path,
1644 tree=tree,
1645 root=root,
1646 rels=_read_relationships(rels_path),
1647 shapes=_top_level_shapes_by_id(root),
1648 shape_ids_by_svg_id=_trace_native_shape_ids(trace),
1649 ))
1650 return states
1651
1652
1653 def _template_shape_for_item(
1654 state: _TemplateRuntimeSlide,
1655 item: TemplateElementSpec,
1656 ) -> ET.Element | None:
1657 """Resolve one metadata item to its generated top-level DrawingML shape."""
1658 shape_ids = [
1659 shape_id
1660 for shape_id in state.shape_ids_by_svg_id.get(item.element_id, [])
1661 if shape_id in state.shapes
1662 ]
1663 if len(shape_ids) == 1:
1664 return state.shapes[shape_ids[0]]
1665 if not shape_ids and item.layer and item.order == 0 and item.tag in {"rect", "g"}:
1666 if _extract_slide_background_xml(
1667 state.slide_path.read_text(encoding="utf-8")
1668 ):
1669 return None
1670 if not shape_ids:
1671 text_hint = (
1672 "; multiline text placeholders require a single-frame text mode "
1673 "and cannot use --no-merge"
1674 if item.placeholder and item.placeholder_carrier_tag == "text"
1675 else ""
1676 )
1677 raise TemplateStructureError(
1678 f"{state.spec.svg_path.name}: metadata element {item.element_id!r} "
1679 f"did not produce one top-level native shape{text_hint}"
1680 )
1681 raise TemplateStructureError(
1682 f"{state.spec.svg_path.name}: metadata element {item.element_id!r} "
1683 f"resolved to {len(shape_ids)} top-level shapes; use one direct SVG element"
1684 )
1685
1686
1687 def _shape_transform(shape: ET.Element) -> ET.Element | None:
1688 """Return the direct DrawingML transform for one top-level shape."""
1689 paths = {
1690 f"{{{PML_NS}}}sp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
1691 f"{{{PML_NS}}}pic": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
1692 f"{{{PML_NS}}}cxnSp": f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm",
1693 f"{{{PML_NS}}}graphicFrame": f"{{{PML_NS}}}xfrm",
1694 f"{{{PML_NS}}}grpSp": f"{{{PML_NS}}}grpSpPr/{{{DML_NS}}}xfrm",
1695 }
1696 path = paths.get(shape.tag)
1697 return shape.find(path) if path is not None else None
1698
1699
1700 def _int_attr(elem: ET.Element, name: str, context: str) -> int:
1701 try:
1702 return int(elem.attrib[name])
1703 except (KeyError, ValueError) as exc:
1704 raise TemplateStructureError(f"{context} has invalid {name!r}") from exc
1705
1706
1707 def _flatten_group_transform(
1708 group: ET.Element,
1709 carrier: ET.Element,
1710 *,
1711 context: str,
1712 ) -> None:
1713 """Map a single group's child transform into slide coordinates."""
1714 group_xfrm = _shape_transform(group)
1715 carrier_xfrm = _shape_transform(carrier)
1716 if group_xfrm is None or carrier_xfrm is None:
1717 raise TemplateStructureError(
1718 f"{context} cannot be unwrapped because its DrawingML transform is missing"
1719 )
1720 if any(group_xfrm.get(name) is not None for name in ("rot", "flipH", "flipV")):
1721 raise TemplateStructureError(
1722 f"{context} wrapper carries an unsupported group rotation or flip"
1723 )
1724 group_off = group_xfrm.find(f"{{{DML_NS}}}off")
1725 group_ext = group_xfrm.find(f"{{{DML_NS}}}ext")
1726 child_off = group_xfrm.find(f"{{{DML_NS}}}chOff")
1727 child_ext = group_xfrm.find(f"{{{DML_NS}}}chExt")
1728 carrier_off = carrier_xfrm.find(f"{{{DML_NS}}}off")
1729 carrier_ext = carrier_xfrm.find(f"{{{DML_NS}}}ext")
1730 if any(value is None for value in (
1731 group_off,
1732 group_ext,
1733 child_off,
1734 child_ext,
1735 carrier_off,
1736 carrier_ext,
1737 )):
1738 raise TemplateStructureError(
1739 f"{context} cannot be unwrapped because its group transform is incomplete"
1740 )
1741 child_width = _int_attr(child_ext, "cx", context)
1742 child_height = _int_attr(child_ext, "cy", context)
1743 if child_width <= 0 or child_height <= 0:
1744 raise TemplateStructureError(f"{context} group child extent must be positive")
1745 scale_x = _int_attr(group_ext, "cx", context) / child_width
1746 scale_y = _int_attr(group_ext, "cy", context) / child_height
1747 mapped_x = _int_attr(group_off, "x", context) + round(
1748 (_int_attr(carrier_off, "x", context) - _int_attr(child_off, "x", context))
1749 * scale_x
1750 )
1751 mapped_y = _int_attr(group_off, "y", context) + round(
1752 (_int_attr(carrier_off, "y", context) - _int_attr(child_off, "y", context))
1753 * scale_y
1754 )
1755 carrier_off.set("x", str(mapped_x))
1756 carrier_off.set("y", str(mapped_y))
1757 carrier_ext.set(
1758 "cx",
1759 str(round(_int_attr(carrier_ext, "cx", context) * scale_x)),
1760 )
1761 carrier_ext.set(
1762 "cy",
1763 str(round(_int_attr(carrier_ext, "cy", context) * scale_y)),
1764 )
1765
1766
1767 def _unwrap_placeholder_carrier(
1768 state: _TemplateRuntimeSlide,
1769 item: TemplateElementSpec,
1770 ) -> ET.Element:
1771 """Remove one SVG-only slot wrapper and return its top-level carrier."""
1772 wrapper = _template_shape_for_item(state, item)
1773 if wrapper is None:
1774 raise TemplateStructureError(
1775 f"{state.spec.svg_path.name}: placeholder {item.element_id!r} cannot "
1776 "be a slide background"
1777 )
1778 if item.tag != "g" or wrapper.tag != f"{{{PML_NS}}}grpSp":
1779 return wrapper
1780 carriers = [
1781 child for child in wrapper if child.tag in _TOP_LEVEL_SHAPE_TAGS
1782 ]
1783 if len(carriers) != 1:
1784 raise TemplateStructureError(
1785 f"{state.spec.svg_path.name}: placeholder group {item.element_id!r} "
1786 f"converted to {len(carriers)} native children; expected one carrier"
1787 )
1788 carrier = carriers[0]
1789 _flatten_group_transform(
1790 wrapper,
1791 carrier,
1792 context=f"{state.spec.svg_path.name} placeholder {item.element_id!r}",
1793 )
1794 sp_tree = _slide_sp_tree(state)
1795 try:
1796 wrapper_index = list(sp_tree).index(wrapper)
1797 except ValueError as exc:
1798 raise TemplateStructureError(
1799 f"{state.spec.svg_path.name}: placeholder wrapper "
1800 f"{item.element_id!r} is not top-level"
1801 ) from exc
1802 wrapper.remove(carrier)
1803 sp_tree.remove(wrapper)
1804 sp_tree.insert(wrapper_index, carrier)
1805 wrapper_id = _shape_id(wrapper)
1806 carrier_id = _shape_id(carrier)
1807 if wrapper_id:
1808 state.shapes.pop(wrapper_id, None)
1809 if carrier_id:
1810 state.shapes[carrier_id] = carrier
1811 return carrier
1812
1813
1814 def _slide_sp_tree(state: _TemplateRuntimeSlide) -> ET.Element:
1815 sp_tree = state.root.find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
1816 if sp_tree is None:
1817 raise RuntimeError(f"Slide has no p:spTree: {state.slide_path}")
1818 return sp_tree
1819
1820
1821 def _append_shape_to_runtime_slide(
1822 state: _TemplateRuntimeSlide,
1823 elem: ET.Element,
1824 ) -> None:
1825 """Append a generated helper shape to one Slide with fresh object ids."""
1826 existing_ids = [
1827 int(cnv.attrib["id"])
1828 for cnv in state.root.iter(f"{{{PML_NS}}}cNvPr")
1829 if cnv.attrib.get("id", "").isdigit()
1830 ]
1831 clone = ET.fromstring(ET.tostring(elem, encoding="utf-8"))
1832 _renumber_shape_ids(clone, max(existing_ids, default=1) + 1)
1833 _slide_sp_tree(state).append(clone)
1834
1835
1836 def _presentation_slide_size_emu(extract_dir: Path) -> tuple[int, int]:
1837 """Return the package slide size in EMU."""
1838 presentation_path = extract_dir / "ppt" / "presentation.xml"
1839 root = ET.parse(presentation_path).getroot()
1840 slide_size = root.find(f"{{{PML_NS}}}sldSz")
1841 if slide_size is None:
1842 raise TemplateStructureError("presentation.xml has no p:sldSz")
1843 try:
1844 width = int(slide_size.attrib["cx"])
1845 height = int(slide_size.attrib["cy"])
1846 except (KeyError, ValueError) as exc:
1847 raise TemplateStructureError("presentation.xml has an invalid p:sldSz") from exc
1848 if width <= 0 or height <= 0:
1849 raise TemplateStructureError("presentation.xml p:sldSz must be positive")
1850 return width, height
1851
1852
1853 def _solid_background_xml_from_shape(
1854 shape: ET.Element,
1855 slide_size_emu: tuple[int, int],
1856 ) -> str | None:
1857 """Convert one exact full-slide solid rectangle shape into p:bg XML."""
1858 if shape.tag != f"{{{PML_NS}}}sp":
1859 return None
1860 if shape.find(f"{{{PML_NS}}}txBody") is not None:
1861 return None
1862 sp_pr = shape.find(f"{{{PML_NS}}}spPr")
1863 if sp_pr is None:
1864 return None
1865 xfrm = sp_pr.find(f"{{{DML_NS}}}xfrm")
1866 if xfrm is None or xfrm.attrib:
1867 return None
1868 off = xfrm.find(f"{{{DML_NS}}}off")
1869 ext = xfrm.find(f"{{{DML_NS}}}ext")
1870 if off is None or ext is None:
1871 return None
1872 try:
1873 bounds = (
1874 int(off.attrib["x"]),
1875 int(off.attrib["y"]),
1876 int(ext.attrib["cx"]),
1877 int(ext.attrib["cy"]),
1878 )
1879 except (KeyError, ValueError):
1880 return None
1881 if bounds != (0, 0, *slide_size_emu):
1882 return None
1883
1884 geometry = sp_pr.find(f"{{{DML_NS}}}prstGeom")
1885 if geometry is None or geometry.attrib.get("prst") != "rect":
1886 return None
1887 solid_fill = sp_pr.find(f"{{{DML_NS}}}solidFill")
1888 if solid_fill is None:
1889 return None
1890 competing_fills = {
1891 f"{{{DML_NS}}}noFill",
1892 f"{{{DML_NS}}}gradFill",
1893 f"{{{DML_NS}}}blipFill",
1894 f"{{{DML_NS}}}pattFill",
1895 f"{{{DML_NS}}}grpFill",
1896 }
1897 if any(child.tag in competing_fills for child in sp_pr):
1898 return None
1899 line = sp_pr.find(f"{{{DML_NS}}}ln")
1900 if line is not None and line.find(f"{{{DML_NS}}}noFill") is None:
1901 return None
1902 for effect_tag in (f"{{{DML_NS}}}effectLst", f"{{{DML_NS}}}effectDag"):
1903 effect = sp_pr.find(effect_tag)
1904 if effect is not None and (effect.attrib or list(effect)):
1905 return None
1906
1907 background = ET.Element(f"{{{PML_NS}}}bg")
1908 background_props = ET.SubElement(background, f"{{{PML_NS}}}bgPr")
1909 background_props.append(
1910 ET.fromstring(ET.tostring(solid_fill, encoding="utf-8"))
1911 )
1912 ET.SubElement(background_props, f"{{{DML_NS}}}effectLst")
1913 return ET.tostring(background, encoding="unicode")
1914
1915
1916 def _remove_template_shape(
1917 state: _TemplateRuntimeSlide,
1918 shape: ET.Element,
1919 ) -> None:
1920 sp_tree = _slide_sp_tree(state)
1921 if shape not in list(sp_tree):
1922 raise TemplateStructureError(
1923 f"{state.spec.svg_path.name}: structure shape is not slide-local"
1924 )
1925 sp_tree.remove(shape)
1926
1927
1928 def _move_template_background(
1929 states: list[_TemplateRuntimeSlide],
1930 target_path: Path,
1931 ) -> str:
1932 backgrounds = [
1933 _extract_slide_background_xml(state.slide_path.read_text(encoding="utf-8"))
1934 for state in states
1935 ]
1936 if not backgrounds or any(background is None for background in backgrounds):
1937 raise TemplateStructureError(
1938 "Template background metadata must resolve to an explicit background "
1939 "on every affected slide"
1940 )
1941 canonical_backgrounds = set()
1942 for background in backgrounds:
1943 if background is None:
1944 continue
1945 wrapper = ET.fromstring(
1946 f'<root xmlns:p="{PML_NS}" xmlns:a="{DML_NS}">{background}</root>'
1947 )
1948 canonical_backgrounds.add(
1949 ET.tostring(list(wrapper)[0], encoding="utf-8")
1950 )
1951 if len(canonical_backgrounds) != 1:
1952 slide_names = ", ".join(state.spec.svg_path.name for state in states)
1953 raise TemplateStructureError(
1954 f"Explicit template background differs across slides: {slide_names}"
1955 )
1956 background_xml = backgrounds[0]
1957 if background_xml is None:
1958 raise TemplateStructureError("Template background is unexpectedly empty")
1959 target_xml = target_path.read_text(encoding="utf-8")
1960 updated = _put_background_on_part(target_xml, background_xml)
1961 if updated is None:
1962 raise TemplateStructureError(
1963 f"Cannot install explicit background on {target_path.name}"
1964 )
1965 target_path.write_text(updated, encoding="utf-8")
1966 for state in states:
1967 c_sld = state.root.find(f"{{{PML_NS}}}cSld")
1968 background = (
1969 c_sld.find(f"{{{PML_NS}}}bg") if c_sld is not None else None
1970 )
1971 if c_sld is None or background is None:
1972 raise TemplateStructureError(
1973 f"{state.spec.svg_path.name}: explicit background disappeared "
1974 "during explicit Layout structure assembly"
1975 )
1976 c_sld.remove(background)
1977 return background_xml
1978
1979
1980 def _move_template_solid_background_shapes(
1981 states: list[_TemplateRuntimeSlide],
1982 shapes: list[ET.Element],
1983 target_path: Path,
1984 slide_size_emu: tuple[int, int],
1985 ) -> str | None:
1986 """Move repeated full-slide solid rects into a master/layout p:bg."""
1987 backgrounds = [
1988 _solid_background_xml_from_shape(shape, slide_size_emu)
1989 for shape in shapes
1990 ]
1991 if not any(backgrounds):
1992 return None
1993 if any(background is None for background in backgrounds):
1994 raise TemplateStructureError(
1995 "A template background resolves to a full-slide solid rect on only "
1996 "some slides sharing the structure"
1997 )
1998 canonical = {background for background in backgrounds if background is not None}
1999 if len(canonical) != 1:
2000 slide_names = ", ".join(state.spec.svg_path.name for state in states)
2001 raise TemplateStructureError(
2002 f"Explicit template solid background differs across slides: {slide_names}"
2003 )
2004 background_xml = backgrounds[0]
2005 if background_xml is None:
2006 return None
2007 target_xml = target_path.read_text(encoding="utf-8")
2008 updated = _put_background_on_part(target_xml, background_xml)
2009 if updated is None:
2010 raise TemplateStructureError(
2011 f"Cannot install explicit solid background on {target_path.name}"
2012 )
2013 target_path.write_text(updated, encoding="utf-8")
2014 for state, shape in zip(states, shapes):
2015 _remove_template_shape(state, shape)
2016 return background_xml
2017
2018
2019 def _set_slide_tree_background(
2020 state: _TemplateRuntimeSlide,
2021 background_xml: str,
2022 ) -> None:
2023 """Replace the slide tree's p:bg with explicit background XML."""
2024 c_sld = state.root.find(f"{{{PML_NS}}}cSld")
2025 if c_sld is None:
2026 raise TemplateStructureError(
2027 f"{state.spec.svg_path.name}: slide has no p:cSld"
2028 )
2029 for existing in list(c_sld):
2030 if existing.tag == f"{{{PML_NS}}}bg":
2031 c_sld.remove(existing)
2032 background = ET.fromstring(background_xml)
2033 sp_tree_tag = f"{{{PML_NS}}}spTree"
2034 insert_at = next(
2035 (index for index, child in enumerate(c_sld) if child.tag == sp_tree_tag),
2036 0,
2037 )
2038 c_sld.insert(insert_at, background)
2039
2040
2041 def _apply_template_slide_backgrounds(
2042 states: list[_TemplateRuntimeSlide],
2043 slide_size_emu: tuple[int, int],
2044 ) -> dict[str, str]:
2045 """Compile one-page solid backgrounds into slide-level p:bg."""
2046 applied: dict[str, str] = {}
2047 for state in states:
2048 items = [
2049 item for item in state.spec.elements
2050 if item.layer == "slide" and item.is_background
2051 ]
2052 if not items:
2053 continue
2054 item = items[0]
2055 shape = _template_shape_for_item(state, item)
2056 if shape is None:
2057 c_sld = state.root.find(f"{{{PML_NS}}}cSld")
2058 background = (
2059 c_sld.find(f"{{{PML_NS}}}bg")
2060 if c_sld is not None
2061 else None
2062 )
2063 if background is None:
2064 raise TemplateStructureError(
2065 f"{state.spec.svg_path.name}: slide background disappeared "
2066 "during explicit Layout structure assembly"
2067 )
2068 applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = ET.tostring(
2069 background,
2070 encoding="unicode",
2071 )
2072 continue
2073 background_xml = _solid_background_xml_from_shape(shape, slide_size_emu)
2074 if background_xml is None:
2075 raise TemplateStructureError(
2076 f"{state.spec.svg_path.name}: {item.element_id!r} must remain an "
2077 "exact full-slide solid rectangle"
2078 )
2079 _remove_template_shape(state, shape)
2080 _set_slide_tree_background(state, background_xml)
2081 applied[f"ppt/slides/slide{state.spec.slide_num}.xml"] = background_xml
2082 return applied
2083
2084
2085 def _move_template_static_shape(
2086 states: list[_TemplateRuntimeSlide],
2087 item: TemplateElementSpec,
2088 target_path: Path,
2089 target_rels_path: Path,
2090 slide_size_emu: tuple[int, int],
2091 ) -> str | None:
2092 shapes = [_template_shape_for_item(state, item) for state in states]
2093 if any(shape is None for shape in shapes):
2094 if not all(shape is None for shape in shapes):
2095 raise TemplateStructureError(
2096 f"{item.element_id}: structure item is a background on only some slides"
2097 )
2098 return _move_template_background(states, target_path)
2099
2100 resolved_shapes = [shape for shape in shapes if shape is not None]
2101 if item.is_background:
2102 background_xml = _move_template_solid_background_shapes(
2103 states,
2104 resolved_shapes,
2105 target_path,
2106 slide_size_emu,
2107 )
2108 if background_xml is None:
2109 raise TemplateStructureError(
2110 f"{item.element_id!r} must compile to one exact p:bg payload"
2111 )
2112 return background_xml
2113 canonical = {
2114 _canonical_shape_xml(shape, state.rels)
2115 for state, shape in zip(states, resolved_shapes)
2116 }
2117 if len(canonical) != 1:
2118 slide_names = ", ".join(state.spec.svg_path.name for state in states)
2119 raise TemplateStructureError(
2120 f"Explicit structure element {item.element_id!r} differs across slides: "
2121 f"{slide_names}"
2122 )
2123 for state, shape in zip(states, resolved_shapes):
2124 shape_id = _shape_id(shape)
2125 if shape_id and shape_id in _timing_shape_ids(state.root):
2126 raise TemplateStructureError(
2127 f"{state.spec.svg_path.name}: structure element {item.element_id!r} "
2128 "is referenced by slide timing"
2129 )
2130 if not _shape_relationships_supported(shape, state.rels):
2131 raise TemplateStructureError(
2132 f"{state.spec.svg_path.name}: structure element {item.element_id!r} "
2133 "uses a non-image or external relationship"
2134 )
2135
2136 prototype_state = states[0]
2137 prototype_shape = resolved_shapes[0]
2138 target_shape = _copy_shape_relationships_to_part(
2139 prototype_shape,
2140 prototype_state.rels,
2141 target_rels_path,
2142 )
2143 _set_shape_name(target_shape, f"{item.element_id} {item.layer.title()}")
2144 _append_shape_to_part(target_path, target_shape)
2145 for state, shape in zip(states, resolved_shapes):
2146 _remove_template_shape(state, shape)
2147 return None
2148
2149
2150 def _shape_bounds_emu(
2151 shape: ET.Element,
2152 override_px: tuple[float, float, float, float] | None,
2153 ) -> tuple[int, int, int, int]:
2154 if override_px is not None:
2155 x, y, width, height = override_px
2156 return tuple(
2157 round(value * EMU_PER_PX)
2158 for value in (x, y, width, height)
2159 )
2160
2161 xfrm = shape.find(f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm")
2162 if xfrm is None:
2163 xfrm = shape.find(f"{{{PML_NS}}}xfrm")
2164 if xfrm is None:
2165 raise TemplateStructureError(
2166 "Placeholder shape has no directly readable DrawingML transform; "
2167 "set data-pptx-bounds"
2168 )
2169 off = xfrm.find(f"{{{DML_NS}}}off")
2170 ext = xfrm.find(f"{{{DML_NS}}}ext")
2171 if off is None or ext is None:
2172 raise TemplateStructureError("Placeholder transform has no a:off/a:ext")
2173 try:
2174 return (
2175 int(off.attrib["x"]),
2176 int(off.attrib["y"]),
2177 int(ext.attrib["cx"]),
2178 int(ext.attrib["cy"]),
2179 )
2180 except (KeyError, ValueError) as exc:
2181 raise TemplateStructureError("Placeholder transform is invalid") from exc
2182
2183
2184 def _replace_shape_xfrm(
2185 sp_pr: ET.Element,
2186 bounds: tuple[int, int, int, int],
2187 ) -> None:
2188 for existing in list(sp_pr):
2189 if existing.tag == f"{{{DML_NS}}}xfrm":
2190 sp_pr.remove(existing)
2191 x, y, width, height = bounds
2192 xfrm = ET.Element(f"{{{DML_NS}}}xfrm")
2193 ET.SubElement(xfrm, f"{{{DML_NS}}}off", {"x": str(x), "y": str(y)})
2194 ET.SubElement(
2195 xfrm,
2196 f"{{{DML_NS}}}ext",
2197 {"cx": str(width), "cy": str(height)},
2198 )
2199 sp_pr.insert(0, xfrm)
2200
2201
2202 def _placeholder_vertical_anchor(
2203 source_bounds: tuple[int, int, int, int],
2204 target_bounds: tuple[int, int, int, int],
2205 ) -> str:
2206 """Preserve an intentionally centered carrier inside its full slot frame."""
2207 _, source_y, _, source_height = source_bounds
2208 _, target_y, _, target_height = target_bounds
2209 source_center = source_y + source_height / 2
2210 target_center = target_y + target_height / 2
2211 return (
2212 "ctr"
2213 if abs(source_center - target_center) <= target_height * 0.2
2214 else "t"
2215 )
2216
2217
2218 def _normalize_placeholder_body_properties(
2219 body_pr: ET.Element,
2220 source_bounds: tuple[int, int, int, int],
2221 target_bounds: tuple[int, int, int, int],
2222 ) -> None:
2223 """Make a full-frame placeholder wrap text while preserving vertical intent."""
2224 body_pr.set("wrap", "square")
2225 body_pr.set("anchor", _placeholder_vertical_anchor(source_bounds, target_bounds))
2226 body_pr.set("anchorCtr", "0")
2227 autofit_tags = {
2228 f"{{{DML_NS}}}noAutofit",
2229 f"{{{DML_NS}}}normAutofit",
2230 f"{{{DML_NS}}}spAutoFit",
2231 }
2232 for child in list(body_pr):
2233 if child.tag in autofit_tags:
2234 body_pr.remove(child)
2235 body_pr.append(ET.Element(f"{{{DML_NS}}}noAutofit"))
2236
2237
2238 def _apply_layout_frame_to_placeholder_carrier(
2239 shape: ET.Element,
2240 item: TemplateElementSpec,
2241 ) -> None:
2242 """Use the reusable Layout bounds on one template-review Slide carrier."""
2243 if item.placeholder_bounds is None:
2244 raise TemplateStructureError(
2245 f"Placeholder {item.element_id!r} has no reusable Layout bounds"
2246 )
2247 source_bounds = _shape_bounds_emu(shape, None)
2248 target_bounds = _shape_bounds_emu(shape, item.placeholder_bounds)
2249 if shape.tag in {f"{{{PML_NS}}}sp", f"{{{PML_NS}}}pic"}:
2250 sp_pr = shape.find(f"{{{PML_NS}}}spPr")
2251 if sp_pr is None:
2252 raise TemplateStructureError(
2253 f"Placeholder {item.element_id!r} has no p:spPr"
2254 )
2255 _replace_shape_xfrm(sp_pr, target_bounds)
2256 elif shape.tag == f"{{{PML_NS}}}graphicFrame":
2257 xfrm = shape.find(f"{{{PML_NS}}}xfrm")
2258 if xfrm is None:
2259 xfrm = ET.Element(f"{{{PML_NS}}}xfrm")
2260 shape.insert(1, xfrm)
2261 for child in list(xfrm):
2262 if child.tag in {f"{{{DML_NS}}}off", f"{{{DML_NS}}}ext"}:
2263 xfrm.remove(child)
2264 x, y, width, height = target_bounds
2265 ET.SubElement(xfrm, f"{{{DML_NS}}}off", {"x": str(x), "y": str(y)})
2266 ET.SubElement(
2267 xfrm,
2268 f"{{{DML_NS}}}ext",
2269 {"cx": str(width), "cy": str(height)},
2270 )
2271 else:
2272 raise TemplateStructureError(
2273 f"Placeholder {item.element_id!r} cannot use Layout bounds on "
2274 f"DrawingML element {shape.tag.rsplit('}', 1)[-1]!r}"
2275 )
2276
2277 tx_body = shape.find(f"{{{PML_NS}}}txBody")
2278 if tx_body is None:
2279 return
2280 body_pr = tx_body.find(f"{{{DML_NS}}}bodyPr")
2281 if body_pr is None:
2282 body_pr = ET.Element(f"{{{DML_NS}}}bodyPr")
2283 tx_body.insert(0, body_pr)
2284 _normalize_placeholder_body_properties(
2285 body_pr,
2286 source_bounds,
2287 target_bounds,
2288 )
2289
2290
2291 def _layout_level_one_paragraph_properties(
2292 list_style: ET.Element,
2293 ) -> ET.Element:
2294 """Return the Layout list style's level-one paragraph properties."""
2295 level_tag = f"{{{DML_NS}}}lvl1pPr"
2296 level_props = list_style.find(level_tag)
2297 if level_props is None:
2298 level_props = ET.Element(level_tag)
2299 trailing_tags = {
2300 f"{{{DML_NS}}}lvl{level}pPr" for level in range(2, 10)
2301 }
2302 trailing_tags.add(f"{{{DML_NS}}}extLst")
2303 insert_at = next(
2304 (
2305 index
2306 for index, child in enumerate(list_style)
2307 if child.tag in trailing_tags
2308 ),
2309 len(list_style),
2310 )
2311 list_style.insert(insert_at, level_props)
2312 return level_props
2313
2314
2315 def _set_layout_level_one_default_size(
2316 list_style: ET.Element,
2317 source_run_pr: ET.Element | None,
2318 ) -> None:
2319 """Persist the prototype run size as the Layout's level-one text default."""
2320 if source_run_pr is None or source_run_pr.get("sz") is None:
2321 return
2322 level_props = _layout_level_one_paragraph_properties(list_style)
2323 default_props = level_props.find(f"{{{DML_NS}}}defRPr")
2324 if default_props is None:
2325 default_props = ET.Element(f"{{{DML_NS}}}defRPr")
2326 ext_tag = f"{{{DML_NS}}}extLst"
2327 insert_at = next(
2328 (
2329 index
2330 for index, child in enumerate(level_props)
2331 if child.tag == ext_tag
2332 ),
2333 len(level_props),
2334 )
2335 level_props.insert(insert_at, default_props)
2336 default_props.set("sz", source_run_pr.get("sz", ""))
2337
2338
2339 def _set_no_bullet_paragraph_properties(
2340 paragraph_props: ET.Element,
2341 *,
2342 replace_existing: bool = False,
2343 ) -> None:
2344 """Disable inherited bullets and hanging indent for a prose paragraph."""
2345 if replace_existing:
2346 for child in list(paragraph_props):
2347 if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS:
2348 paragraph_props.remove(child)
2349
2350 bullet_choice = next(
2351 (
2352 child
2353 for child in paragraph_props
2354 if child.tag in _PARAGRAPH_BULLET_CHOICE_TAGS
2355 ),
2356 None,
2357 )
2358 if bullet_choice is not None and bullet_choice.tag != f"{{{DML_NS}}}buNone":
2359 return
2360 if bullet_choice is None:
2361 insert_at = next(
2362 (
2363 index
2364 for index, child in enumerate(paragraph_props)
2365 if child.tag in _PARAGRAPH_PROPERTIES_TRAILING_TAGS
2366 ),
2367 len(paragraph_props),
2368 )
2369 paragraph_props.insert(insert_at, ET.Element(f"{{{DML_NS}}}buNone"))
2370
2371 paragraph_props.set("marL", "0")
2372 paragraph_props.set("indent", "0")
2373
2374
2375 def _placeholder_text_body(
2376 source_shape: ET.Element,
2377 item: TemplateElementSpec,
2378 ) -> ET.Element:
2379 tx_body = ET.Element(f"{{{PML_NS}}}txBody")
2380 source_tx_body = source_shape.find(f"{{{PML_NS}}}txBody")
2381 source_body_pr = (
2382 source_tx_body.find(f"{{{DML_NS}}}bodyPr")
2383 if source_tx_body is not None
2384 else None
2385 )
2386 source_lst_style = (
2387 source_tx_body.find(f"{{{DML_NS}}}lstStyle")
2388 if source_tx_body is not None
2389 else None
2390 )
2391 source_run_pr = (
2392 source_tx_body.find(f".//{{{DML_NS}}}rPr")
2393 if source_tx_body is not None
2394 else None
2395 )
2396 body_pr = (
2397 ET.fromstring(ET.tostring(source_body_pr, encoding="utf-8"))
2398 if source_body_pr is not None
2399 else ET.Element(f"{{{DML_NS}}}bodyPr")
2400 )
2401 target_bounds = _shape_bounds_emu(source_shape, item.placeholder_bounds)
2402 try:
2403 source_bounds = _shape_bounds_emu(source_shape, None)
2404 except TemplateStructureError:
2405 # Composite proxy content may compile to p:grpSp, whose transform is
2406 # intentionally not reused for the Layout's synthetic p:sp carrier.
2407 # The explicit design-zone bounds remain the authoritative frame.
2408 source_bounds = target_bounds
2409 _normalize_placeholder_body_properties(
2410 body_pr,
2411 source_bounds,
2412 target_bounds,
2413 )
2414 tx_body.append(body_pr)
2415 list_style = (
2416 ET.fromstring(ET.tostring(source_lst_style, encoding="utf-8"))
2417 if source_lst_style is not None
2418 else ET.Element(f"{{{DML_NS}}}lstStyle")
2419 )
2420 _set_layout_level_one_default_size(list_style, source_run_pr)
2421 if item.placeholder in {"body", "subtitle"}:
2422 _set_no_bullet_paragraph_properties(
2423 _layout_level_one_paragraph_properties(list_style),
2424 replace_existing=item.placeholder == "body",
2425 )
2426 tx_body.append(list_style)
2427
2428 paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
2429 source_paragraph_props = (
2430 source_tx_body.find(f"{{{DML_NS}}}p/{{{DML_NS}}}pPr")
2431 if source_tx_body is not None
2432 else None
2433 )
2434 paragraph_props = (
2435 ET.fromstring(ET.tostring(source_paragraph_props, encoding="utf-8"))
2436 if source_paragraph_props is not None
2437 else None
2438 )
2439 if item.placeholder in {"body", "subtitle"}:
2440 if paragraph_props is None:
2441 paragraph_props = ET.Element(f"{{{DML_NS}}}pPr")
2442 _set_no_bullet_paragraph_properties(paragraph_props)
2443 if paragraph_props is not None:
2444 paragraph.append(paragraph_props)
2445 if item.placeholder in {"slide-number", "date"}:
2446 field_type = (
2447 "slidenum"
2448 if item.placeholder == "slide-number"
2449 else "datetimeFigureOut"
2450 )
2451 field = ET.SubElement(
2452 paragraph,
2453 f"{{{DML_NS}}}fld",
2454 {"id": f"{{{str(uuid.uuid4()).upper()}}}", "type": field_type},
2455 )
2456 if source_run_pr is not None:
2457 field.append(ET.fromstring(ET.tostring(source_run_pr, encoding="utf-8")))
2458 source_text = ""
2459 if source_tx_body is not None:
2460 source_text = "".join(
2461 text.text or ""
2462 for text in source_tx_body.findall(f".//{{{DML_NS}}}t")
2463 )
2464 field_text = (
2465 "‹#›"
2466 if item.placeholder == "slide-number"
2467 else source_text or "Date"
2468 )
2469 ET.SubElement(field, f"{{{DML_NS}}}t").text = field_text
2470 else:
2471 run = ET.SubElement(paragraph, f"{{{DML_NS}}}r")
2472 if source_run_pr is not None:
2473 run.append(ET.fromstring(ET.tostring(source_run_pr, encoding="utf-8")))
2474 ET.SubElement(run, f"{{{DML_NS}}}t").text = _TEMPLATE_PLACEHOLDER_PROMPTS.get(
2475 item.placeholder or "",
2476 "Click to add content",
2477 )
2478 ET.SubElement(paragraph, f"{{{DML_NS}}}endParaRPr", {"lang": "en-US"})
2479 return tx_body
2480
2481
2482 def _set_placeholder_no_inherited_bullets(
2483 shape: ET.Element,
2484 item: TemplateElementSpec,
2485 ) -> None:
2486 """Keep prose bullet-free while preserving explicit subtitle bullets."""
2487 if item.placeholder not in {"body", "subtitle"}:
2488 return
2489 tx_body = shape.find(f"{{{PML_NS}}}txBody")
2490 if tx_body is None:
2491 return
2492 for paragraph in tx_body.findall(f"{{{DML_NS}}}p"):
2493 paragraph_props = paragraph.find(f"{{{DML_NS}}}pPr")
2494 if paragraph_props is None:
2495 paragraph_props = ET.Element(f"{{{DML_NS}}}pPr")
2496 paragraph.insert(0, paragraph_props)
2497 _set_no_bullet_paragraph_properties(
2498 paragraph_props,
2499 replace_existing=item.placeholder == "body",
2500 )
2501
2502
2503 def _set_placeholder_theme_font_role(
2504 shape: ET.Element,
2505 item: TemplateElementSpec,
2506 theme_font_spec: ThemeFontSpec | None,
2507 ) -> None:
2508 """Force semantic text placeholders onto the correct theme font role."""
2509 if theme_font_spec is None:
2510 return
2511 if item.placeholder == "title":
2512 prefix = "+mj"
2513 elif item.placeholder in TEMPLATE_PLACEHOLDER_TYPES:
2514 prefix = "+mn"
2515 else:
2516 return
2517 for props_tag in ("rPr", "defRPr", "endParaRPr"):
2518 for props in shape.iter(f"{{{DML_NS}}}{props_tag}"):
2519 for font_tag, suffix in (("latin", "lt"), ("ea", "ea"), ("cs", "cs")):
2520 font = props.find(f"{{{DML_NS}}}{font_tag}")
2521 if font is not None:
2522 font.set("typeface", f"{prefix}-{suffix}")
2523
2524
2525 def _layout_placeholder_shape(
2526 source_shape: ET.Element,
2527 item: TemplateElementSpec,
2528 placeholder_idx: int | None,
2529 theme_font_spec: ThemeFontSpec | None = None,
2530 ) -> ET.Element:
2531 """Build one reusable p:sp placeholder from a prototype slide object."""
2532 placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
2533 if placeholder_type is None:
2534 raise TemplateStructureError(
2535 f"Unsupported placeholder type: {item.placeholder!r}"
2536 )
2537 bounds = _shape_bounds_emu(source_shape, item.placeholder_bounds)
2538 shape = ET.Element(f"{{{PML_NS}}}sp")
2539 nv_sp_pr = ET.SubElement(shape, f"{{{PML_NS}}}nvSpPr")
2540 ET.SubElement(
2541 nv_sp_pr,
2542 f"{{{PML_NS}}}cNvPr",
2543 {"id": "2", "name": f"{item.element_id} Placeholder"},
2544 )
2545 c_nv_sp_pr = ET.SubElement(nv_sp_pr, f"{{{PML_NS}}}cNvSpPr")
2546 ET.SubElement(c_nv_sp_pr, f"{{{DML_NS}}}spLocks", {"noGrp": "1"})
2547 nv_pr = ET.SubElement(nv_sp_pr, f"{{{PML_NS}}}nvPr")
2548 placeholder_attrs = {"type": placeholder_type}
2549 if placeholder_idx is not None:
2550 placeholder_attrs["idx"] = str(placeholder_idx)
2551 elif item.placeholder != "title":
2552 raise TemplateStructureError(
2553 f"Placeholder {item.element_id!r} requires an idx"
2554 )
2555 ET.SubElement(nv_pr, f"{{{PML_NS}}}ph", placeholder_attrs)
2556
2557 source_sp_pr = (
2558 source_shape.find(f"{{{PML_NS}}}spPr")
2559 if source_shape.tag == f"{{{PML_NS}}}sp"
2560 else None
2561 )
2562 if source_sp_pr is not None:
2563 sp_pr = ET.fromstring(ET.tostring(source_sp_pr, encoding="utf-8"))
2564 else:
2565 sp_pr = ET.Element(f"{{{PML_NS}}}spPr")
2566 geometry = ET.SubElement(sp_pr, f"{{{DML_NS}}}prstGeom", {"prst": "rect"})
2567 ET.SubElement(geometry, f"{{{DML_NS}}}avLst")
2568 ET.SubElement(sp_pr, f"{{{DML_NS}}}noFill")
2569 line = ET.SubElement(sp_pr, f"{{{DML_NS}}}ln")
2570 ET.SubElement(line, f"{{{DML_NS}}}noFill")
2571 _replace_shape_xfrm(sp_pr, bounds)
2572 shape.append(sp_pr)
2573 shape.append(_placeholder_text_body(source_shape, item))
2574 _set_placeholder_theme_font_role(shape, item, theme_font_spec)
2575 return shape
2576
2577
2578 def _placeholder_binding_proxy(
2579 layout_placeholder: ET.Element,
2580 item: TemplateElementSpec,
2581 ) -> ET.Element:
2582 """Bind a Layout slot invisibly while leaving its visible content ordinary.
2583
2584 An unbound object placeholder can leak its inherited empty frame into a
2585 finished Slide in non-PowerPoint renderers. A hidden matching proxy suppresses
2586 that inheritance. The zero-width transparent run avoids a LibreOffice empty-
2587 placeholder black fill without adding visible content.
2588 """
2589 proxy = ET.fromstring(ET.tostring(layout_placeholder, encoding="utf-8"))
2590 c_nv_pr = next(proxy.iter(f"{{{PML_NS}}}cNvPr"), None)
2591 if c_nv_pr is None:
2592 raise TemplateStructureError(
2593 f"Cannot create placeholder binding for {item.element_id!r}: "
2594 "p:cNvPr is missing"
2595 )
2596 placeholder = proxy.find(f".//{{{PML_NS}}}ph")
2597 if placeholder is None:
2598 raise TemplateStructureError(
2599 f"Cannot create placeholder binding for {item.element_id!r}: "
2600 "p:ph is missing"
2601 )
2602 c_nv_pr.set(
2603 "name",
2604 "Placeholder Binding "
2605 f"{placeholder.get('type', 'body')} {placeholder.get('idx', '0')}",
2606 )
2607 c_nv_pr.set("hidden", "1")
2608 tx_body = proxy.find(f"{{{PML_NS}}}txBody")
2609 if tx_body is None:
2610 raise TemplateStructureError(
2611 f"Cannot create placeholder binding for {item.element_id!r}: "
2612 "p:txBody is missing"
2613 )
2614 for child in list(tx_body):
2615 if child.tag == f"{{{DML_NS}}}p":
2616 tx_body.remove(child)
2617 paragraph = ET.SubElement(tx_body, f"{{{DML_NS}}}p")
2618 run = ET.SubElement(paragraph, f"{{{DML_NS}}}r")
2619 run_props = ET.SubElement(
2620 run,
2621 f"{{{DML_NS}}}rPr",
2622 {"lang": "en-US", "sz": "100"},
2623 )
2624 solid_fill = ET.SubElement(run_props, f"{{{DML_NS}}}solidFill")
2625 color = ET.SubElement(solid_fill, f"{{{DML_NS}}}srgbClr", {"val": "FFFFFF"})
2626 ET.SubElement(color, f"{{{DML_NS}}}alpha", {"val": "0"})
2627 ET.SubElement(run, f"{{{DML_NS}}}t").text = "\u200b"
2628 ET.SubElement(
2629 paragraph,
2630 f"{{{DML_NS}}}endParaRPr",
2631 {"lang": "en-US"},
2632 )
2633 return proxy
2634
2635
2636 def _patch_slide_placeholder(
2637 shape: ET.Element,
2638 item: TemplateElementSpec,
2639 placeholder_idx: int | None,
2640 placeholder_type: str | None = None,
2641 theme_font_spec: ThemeFontSpec | None = None,
2642 ) -> None:
2643 resolved_type = placeholder_type or TEMPLATE_PLACEHOLDER_TYPES.get(
2644 item.placeholder or ""
2645 )
2646 if resolved_type is None:
2647 raise TemplateStructureError(
2648 f"Unsupported placeholder type: {item.placeholder!r}"
2649 )
2650 nv_paths = {
2651 f"{{{PML_NS}}}sp": f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr",
2652 f"{{{PML_NS}}}pic": f"{{{PML_NS}}}nvPicPr/{{{PML_NS}}}nvPr",
2653 f"{{{PML_NS}}}graphicFrame": (
2654 f"{{{PML_NS}}}nvGraphicFramePr/{{{PML_NS}}}nvPr"
2655 ),
2656 }
2657 nv_path = nv_paths.get(shape.tag)
2658 if nv_path is None:
2659 raise TemplateStructureError(
2660 f"Placeholder {item.element_id!r} converted to unsupported "
2661 f"DrawingML element {shape.tag.rsplit('}', 1)[-1]!r}; text/picture/"
2662 "native chart/table placeholders must remain one top-level object"
2663 )
2664 nv_pr = shape.find(nv_path)
2665 if nv_pr is None:
2666 raise TemplateStructureError(
2667 f"Placeholder {item.element_id!r} has no non-visual properties"
2668 )
2669 _set_placeholder_no_inherited_bullets(shape, item)
2670 _set_placeholder_theme_font_role(shape, item, theme_font_spec)
2671 for existing in list(nv_pr):
2672 if existing.tag == f"{{{PML_NS}}}ph":
2673 nv_pr.remove(existing)
2674 placeholder_attrs: dict[str, str] = {}
2675 if (
2676 placeholder_type is not None
2677 or resolved_type != "obj"
2678 or placeholder_idx is None
2679 ):
2680 placeholder_attrs["type"] = resolved_type
2681 if placeholder_idx is not None:
2682 placeholder_attrs["idx"] = str(placeholder_idx)
2683 ph = ET.Element(f"{{{PML_NS}}}ph", placeholder_attrs)
2684 ext_tag = f"{{{PML_NS}}}extLst"
2685 insert_at = next(
2686 (idx for idx, child in enumerate(nv_pr) if child.tag == ext_tag),
2687 len(nv_pr),
2688 )
2689 nv_pr.insert(insert_at, ph)
2690
2691
2692 def _set_template_layout_header_footer(
2693 layout_path: Path,
2694 placeholders: tuple[TemplateElementSpec, ...],
2695 ) -> None:
2696 """Enable declared footer fields for slides newly created from the layout."""
2697 kinds = {item.placeholder for item in placeholders}
2698 if not kinds.intersection({"date", "footer", "slide-number"}):
2699 return
2700 tree = ET.parse(layout_path)
2701 root = tree.getroot()
2702 hf = root.find(f"{{{PML_NS}}}hf")
2703 if hf is None:
2704 hf = ET.Element(f"{{{PML_NS}}}hf")
2705 trailing_tags = {
2706 f"{{{PML_NS}}}timing",
2707 f"{{{PML_NS}}}transition",
2708 f"{{{PML_NS}}}extLst",
2709 }
2710 insert_at = next(
2711 (idx for idx, child in enumerate(root) if child.tag in trailing_tags),
2712 len(root),
2713 )
2714 root.insert(insert_at, hf)
2715 hf.set("hdr", "0")
2716 hf.set("dt", "1" if "date" in kinds else "0")
2717 hf.set("ftr", "1" if "footer" in kinds else "0")
2718 hf.set("sldNum", "1" if "slide-number" in kinds else "0")
2719 _write_xml_tree(layout_path, tree)
2720
2721
2722 def _apply_explicit_layout_structure(
2723 extract_dir: Path,
2724 structure: PptxStructureContext,
2725 specs: list[TemplateSlideSpec],
2726 conversion_traces: list[dict[str, Any]] | None,
2727 theme_font_spec: ThemeFontSpec | None,
2728 *,
2729 use_layout_placeholder_frames: bool = False,
2730 verbose: bool = False,
2731 ) -> tuple[
2732 dict[str, str | None],
2733 dict[str, tuple[str, ...]],
2734 dict[str, str],
2735 dict[str, str],
2736 ]:
2737 """Materialize explicit SVG master/layout/placeholder metadata into OOXML."""
2738 master_parts_by_key = _assign_structured_masters(
2739 extract_dir,
2740 structure,
2741 specs,
2742 )
2743 states = _template_runtime_slides(extract_dir, specs, conversion_traces)
2744 states_by_slide = {state.spec.slide_num: state for state in states}
2745 slide_size_emu = _presentation_slide_size_emu(extract_dir)
2746
2747 expected_backgrounds: dict[str, str | None] = {}
2748 expected_shape_rosters: dict[str, tuple[str, ...]] = {}
2749 states_by_master: dict[str, list[_TemplateRuntimeSlide]] = {}
2750 for state in states:
2751 master_part = master_parts_by_key[state.spec.master_key]
2752 states_by_master.setdefault(master_part, []).append(state)
2753 master_shape_count = 0
2754 for master_part, master_states in states_by_master.items():
2755 master_path = extract_dir / master_part
2756 master_rels_path = _relationships_path_for_part(extract_dir, master_part)
2757 expected_backgrounds[master_part] = _extract_slide_background_xml(
2758 master_path.read_text(encoding="utf-8")
2759 )
2760 _clear_master_placeholder_shapes(master_path)
2761 master_items = master_states[0].spec.master_elements
2762 for item in master_items:
2763 background_xml = _move_template_static_shape(
2764 master_states,
2765 item,
2766 master_path,
2767 master_rels_path,
2768 slide_size_emu,
2769 )
2770 if background_xml is not None:
2771 expected_backgrounds[master_part] = background_xml
2772 master_shape_count += 1
2773
2774 specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
2775 for spec in specs:
2776 specs_by_layout.setdefault(spec.layout_key, []).append(spec)
2777 placeholder_count = 0
2778 layout_shape_count = 0
2779 created_layout_parts: set[str] = set()
2780 layout_parts_by_key: dict[str, str] = {}
2781 for layout_key, layout_specs in specs_by_layout.items():
2782 layout_states = [states_by_slide[spec.slide_num] for spec in layout_specs]
2783 master_parts = {
2784 structure.slide_master_part(spec.slide_num) for spec in layout_specs
2785 }
2786 if len(master_parts) != 1:
2787 raise TemplateStructureError(
2788 f"Layout {layout_key!r} spans multiple slide masters; use distinct "
2789 "layout keys per master"
2790 )
2791 master_part = next(iter(master_parts))
2792 prototype = layout_specs[0]
2793 base_target = structure.slide_layout_target(prototype.slide_num)
2794 base_layout_part = _resolve_package_target(
2795 f"ppt/slides/slide{prototype.slide_num}.xml",
2796 base_target,
2797 )
2798 layout_target, layout_part = _create_custom_layout(
2799 extract_dir,
2800 master_part,
2801 base_layout_part,
2802 prototype.layout_name,
2803 show_master_shapes=prototype.layout_show_master_shapes,
2804 )
2805 layout_path = extract_dir / layout_part
2806 layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
2807 created_layout_parts.add(layout_part)
2808 layout_parts_by_key[layout_key] = layout_part
2809
2810 placeholder_bindings = {
2811 binding.element.element_id: binding
2812 for binding in template_placeholder_bindings(prototype)
2813 }
2814 for item in prototype.elements:
2815 if item.layer == "layout":
2816 background_xml = _move_template_static_shape(
2817 layout_states,
2818 item,
2819 layout_path,
2820 layout_rels_path,
2821 slide_size_emu,
2822 )
2823 if background_xml is not None:
2824 expected_backgrounds[layout_part] = background_xml
2825 layout_shape_count += 1
2826 continue
2827 if not item.placeholder:
2828 continue
2829 proxy_binding = is_proxy_placeholder(item)
2830 if proxy_binding:
2831 placeholder_shapes = [
2832 _template_shape_for_item(state, item)
2833 for state in layout_states
2834 ]
2835 if any(shape is None for shape in placeholder_shapes):
2836 raise TemplateStructureError(
2837 f"Placeholder {item.element_id!r} cannot be a slide background"
2838 )
2839 resolved_shapes = [
2840 shape for shape in placeholder_shapes if shape is not None
2841 ]
2842 else:
2843 resolved_shapes = [
2844 _unwrap_placeholder_carrier(state, item)
2845 for state in layout_states
2846 ]
2847 prototype_shape = resolved_shapes[0]
2848 binding = placeholder_bindings[item.element_id]
2849 assigned_idx = binding.assigned_idx
2850 layout_placeholder = _layout_placeholder_shape(
2851 prototype_shape,
2852 item,
2853 assigned_idx,
2854 theme_font_spec,
2855 )
2856 _append_shape_to_part(layout_path, layout_placeholder)
2857 for state, shape in zip(layout_states, resolved_shapes):
2858 if not proxy_binding:
2859 _patch_slide_placeholder(
2860 shape,
2861 item,
2862 assigned_idx,
2863 theme_font_spec=theme_font_spec,
2864 )
2865 if use_layout_placeholder_frames:
2866 _apply_layout_frame_to_placeholder_carrier(shape, item)
2867 _set_shape_name(
2868 shape,
2869 f"{item.element_id} Placeholder Carrier",
2870 )
2871 else:
2872 _set_shape_name(
2873 shape,
2874 f"{item.element_id} Proxy Content",
2875 )
2876 _append_shape_to_runtime_slide(
2877 state,
2878 _placeholder_binding_proxy(layout_placeholder, item),
2879 )
2880 placeholder_count += 1
2881
2882 _set_template_layout_header_footer(layout_path, prototype.placeholders)
2883 for state in layout_states:
2884 _set_slide_layout_target(state.rels_path, layout_target)
2885
2886 slide_backgrounds = _apply_template_slide_backgrounds(
2887 states,
2888 slide_size_emu,
2889 )
2890 expected_backgrounds.update(slide_backgrounds)
2891 for state in states:
2892 state.root.set(
2893 "showMasterSp",
2894 "1" if state.spec.slide_show_inherited_shapes else "0",
2895 )
2896 _write_xml_tree(state.slide_path, state.tree)
2897 expected_backgrounds.setdefault(
2898 f"ppt/slides/slide{state.spec.slide_num}.xml",
2899 None,
2900 )
2901 expected_shape_rosters[
2902 f"ppt/slides/slide{state.spec.slide_num}.xml"
2903 ] = _top_level_shape_name_roster(state.root)
2904 for part in states_by_master:
2905 expected_backgrounds.setdefault(
2906 part,
2907 _extract_slide_background_xml(
2908 (extract_dir / part).read_text(encoding="utf-8")
2909 ),
2910 )
2911 for part in created_layout_parts:
2912 expected_backgrounds.setdefault(part, None)
2913 for part in (*states_by_master, *created_layout_parts):
2914 expected_shape_rosters[part] = _top_level_shape_name_roster(
2915 ET.parse(extract_dir / part).getroot()
2916 )
2917
2918 if verbose:
2919 print(
2920 " Explicit Layout structure: "
2921 f"{len(states_by_master)} master(s), "
2922 f"{len(specs_by_layout)} layout(s), "
2923 f"{master_shape_count} master element(s), "
2924 f"{layout_shape_count} layout element(s), "
2925 f"{len(slide_backgrounds)} slide background(s), "
2926 f"{placeholder_count} placeholder definition(s)"
2927 )
2928 return (
2929 expected_backgrounds,
2930 expected_shape_rosters,
2931 layout_parts_by_key,
2932 master_parts_by_key,
2933 )
2934
2935
2936 def _apply_preserved_structure(
2937 extract_dir: Path,
2938 specs: list[TemplateSlideSpec],
2939 contract: NativeStructureContract,
2940 conversion_traces: list[dict[str, Any]] | None,
2941 *,
2942 verbose: bool = False,
2943 ) -> None:
2944 """Drop preview-only inherited layers and bind content to source placeholders."""
2945 states = _template_runtime_slides(extract_dir, specs, conversion_traces)
2946 removed_preview_shapes = 0
2947 removed_preview_backgrounds = 0
2948 placeholder_count = 0
2949 for state in states:
2950 removed_background = False
2951 for item in state.spec.elements:
2952 if item.layer not in {"master", "layout"}:
2953 continue
2954 shape = _template_shape_for_item(state, item)
2955 if shape is not None:
2956 _remove_template_shape(state, shape)
2957 removed_preview_shapes += 1
2958 continue
2959 if removed_background:
2960 raise TemplateStructureError(
2961 f"{state.spec.svg_path.name}: multiple inherited preview "
2962 "backgrounds resolved to one slide background"
2963 )
2964 common_slide = state.root.find(f"{{{PML_NS}}}cSld")
2965 background = (
2966 common_slide.find(f"{{{PML_NS}}}bg")
2967 if common_slide is not None
2968 else None
2969 )
2970 if common_slide is None or background is None:
2971 raise TemplateStructureError(
2972 f"{state.spec.svg_path.name}: inherited preview background "
2973 "did not produce a removable slide background"
2974 )
2975 common_slide.remove(background)
2976 removed_background = True
2977 removed_preview_backgrounds += 1
2978
2979 layout = contract.layout(state.spec.layout_key)
2980 for item, source_placeholder in match_native_placeholders(state.spec, layout):
2981 shape = _template_shape_for_item(state, item)
2982 if shape is None:
2983 raise TemplateStructureError(
2984 f"{state.spec.svg_path.name}: placeholder {item.element_id!r} "
2985 "cannot resolve to a slide background"
2986 )
2987 _patch_slide_placeholder(
2988 shape,
2989 item,
2990 source_placeholder.idx,
2991 source_placeholder.placeholder_type,
2992 )
2993 placeholder_count += 1
2994 _write_xml_tree(state.slide_path, state.tree)
2995
2996 if verbose:
2997 print(
2998 " Preserved structure: "
2999 f"{len({spec.layout_key for spec in specs})} source layout(s), "
3000 f"{removed_preview_shapes} preview shape(s) removed, "
3001 f"{removed_preview_backgrounds} preview background(s) removed, "
3002 f"{placeholder_count} source placeholder binding(s)"
3003 )
3004
3005
3006 def _promote_common_chrome_shapes_to_masters(
3007 extract_dir: Path,
3008 structure: PptxStructureContext,
3009 slide_count: int,
3010 conversion_traces: list[dict[str, Any]] | None,
3011 *,
3012 verbose: bool = False,
3013 ) -> int:
3014 """Promote explicit repeated chrome SVG ids to their shared master."""
3015 if not conversion_traces:
3016 return 0
3017 trace_by_slide = {
3018 int(trace.get("slide_num", 0)): trace
3019 for trace in conversion_traces
3020 if trace.get("slide_num") is not None
3021 }
3022 if len(trace_by_slide) < slide_count:
3023 return 0
3024
3025 slides_by_master: dict[str, list[int]] = {}
3026 for slide_num in range(1, slide_count + 1):
3027 master_part = structure.slide_master_part(slide_num)
3028 slides_by_master.setdefault(master_part, []).append(slide_num)
3029
3030 promoted = 0
3031 promoted_roles = 0
3032 for master_part, slide_nums in slides_by_master.items():
3033 if len(slide_nums) < 2:
3034 continue
3035 slide_state: dict[int, dict[str, Any]] = {}
3036 for slide_num in slide_nums:
3037 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
3038 rels_path = extract_dir / "ppt" / "slides" / "_rels" / f"slide{slide_num}.xml.rels"
3039 tree = ET.parse(slide_path)
3040 root = tree.getroot()
3041 slide_state[slide_num] = {
3042 "path": slide_path,
3043 "rels": _read_relationships(rels_path),
3044 "root": root,
3045 "shapes": _top_level_shapes_by_id(root),
3046 "timing_shape_ids": _timing_shape_ids(root),
3047 "tokens": _trace_chrome_shape_ids(trace_by_slide.get(slide_num)),
3048 "tree": tree,
3049 }
3050
3051 # Per token, find the strict-majority identical variant. Slides
3052 # outside every dominant set become cover-layout minority slides.
3053 candidate_sets: dict[str, dict[int, str]] = {}
3054 all_tokens = sorted({
3055 token
3056 for state in slide_state.values()
3057 for token in state["tokens"]
3058 })
3059 for token in all_tokens:
3060 carriers: dict[int, str] = {}
3061 canonical_by_slide: dict[int, bytes] = {}
3062 for slide_num in slide_nums:
3063 state = slide_state[slide_num]
3064 shape_ids = state["tokens"].get(token, [])
3065 if len(shape_ids) != 1:
3066 continue
3067 shape_id = shape_ids[0]
3068 shape = state["shapes"].get(shape_id)
3069 if shape is None:
3070 continue
3071 if shape_id in state["timing_shape_ids"]:
3072 continue
3073 if not _shape_relationships_supported(shape, state["rels"]):
3074 continue
3075 carriers[slide_num] = shape_id
3076 canonical_by_slide[slide_num] = _canonical_shape_xml(
3077 shape,
3078 state["rels"],
3079 )
3080 dominant_xml, dominant_slides = _dominant_variant(canonical_by_slide)
3081 if dominant_xml is None:
3082 continue
3083 if not _is_strict_majority(len(dominant_slides), len(slide_nums)):
3084 continue
3085 candidate_sets[token] = {
3086 slide_num: carriers[slide_num] for slide_num in dominant_slides
3087 }
3088
3089 if not candidate_sets:
3090 continue
3091 content_slides = sorted(set.intersection(
3092 *(set(slides) for slides in candidate_sets.values())
3093 ))
3094 if not _is_strict_majority(len(content_slides), len(slide_nums)):
3095 continue
3096
3097 promotions: list[tuple[str, dict[int, str]]] = []
3098 claimed_shape_ids: dict[int, set[str]] = {
3099 slide_num: set() for slide_num in content_slides
3100 }
3101 for token in sorted(candidate_sets):
3102 shape_ids_by_slide = {
3103 slide_num: candidate_sets[token][slide_num]
3104 for slide_num in content_slides
3105 }
3106 # A flattened nested chrome group can emit several semantic trace
3107 # ids for the same generated DrawingML shape. Claim it once.
3108 if any(
3109 shape_ids_by_slide[slide_num] in claimed_shape_ids[slide_num]
3110 for slide_num in content_slides
3111 ):
3112 continue
3113 for slide_num, shape_id in shape_ids_by_slide.items():
3114 claimed_shape_ids[slide_num].add(shape_id)
3115 promotions.append((token, shape_ids_by_slide))
3116
3117 if not promotions:
3118 continue
3119
3120 # Master shapes always render behind slide-local shapes. Preserve the
3121 # original z-order by promoting only a common leading chrome prefix;
3122 # overlay headers/footers remain slide-local.
3123 token_by_shape_id = {
3124 slide_num: {
3125 shape_ids[slide_num]: token
3126 for token, shape_ids in promotions
3127 }
3128 for slide_num in content_slides
3129 }
3130 leading_token_orders: list[list[str]] = []
3131 for slide_num in content_slides:
3132 order: list[str] = []
3133 for shape_id in slide_state[slide_num]["shapes"]:
3134 token = token_by_shape_id[slide_num].get(shape_id)
3135 if token is None:
3136 break
3137 order.append(token)
3138 leading_token_orders.append(order)
3139
3140 safe_tokens = list(leading_token_orders[0])
3141 for order in leading_token_orders[1:]:
3142 common_length = 0
3143 for expected, actual in zip(safe_tokens, order):
3144 if expected != actual:
3145 break
3146 common_length += 1
3147 safe_tokens = safe_tokens[:common_length]
3148 if not safe_tokens:
3149 break
3150 promotion_by_token = {token: shape_ids for token, shape_ids in promotions}
3151 promotions = [
3152 (token, promotion_by_token[token])
3153 for token in safe_tokens
3154 ]
3155
3156 if not promotions:
3157 continue
3158
3159 master_path = extract_dir / master_part
3160 master_rels_path = _relationships_path_for_part(extract_dir, master_part)
3161 for _token, shape_ids_by_slide in promotions:
3162 first_slide = content_slides[0]
3163 first_state = slide_state[first_slide]
3164 shape = first_state["shapes"][shape_ids_by_slide[first_slide]]
3165 master_shape = _copy_shape_relationships_to_master(
3166 shape,
3167 first_state["rels"],
3168 master_rels_path,
3169 )
3170 _append_shape_to_master(master_path, master_shape)
3171 promoted_roles += 1
3172
3173 for slide_num, shape_id in shape_ids_by_slide.items():
3174 state = slide_state[slide_num]
3175 shape_to_remove = state["shapes"].get(shape_id)
3176 sp_tree = state["root"].find(f".//{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
3177 if sp_tree is not None and shape_to_remove is not None:
3178 sp_tree.remove(shape_to_remove)
3179 promoted += 1
3180
3181 for slide_num in content_slides:
3182 state = slide_state[slide_num]
3183 _write_xml_tree(state["path"], state["tree"])
3184
3185 # Minority slides (covers, section pages) keep every shape
3186 # slide-local and move to a Cover layout that hides the newly
3187 # promoted master chrome, so their rendering never changes.
3188 minority_slides = [
3189 slide_num for slide_num in slide_nums
3190 if slide_num not in set(content_slides)
3191 ]
3192 if minority_slides:
3193 first_minority_rels = (
3194 extract_dir / "ppt" / "slides" / "_rels"
3195 / f"slide{minority_slides[0]}.xml.rels"
3196 )
3197 base_target = _find_relationship_target(
3198 first_minority_rels, SLIDE_LAYOUT_REL_TYPE
3199 )
3200 if not base_target:
3201 raise RuntimeError(
3202 f"Slide {minority_slides[0]} has no slide layout relationship"
3203 )
3204 base_layout_part = _resolve_package_target(
3205 f"ppt/slides/slide{minority_slides[0]}.xml", base_target
3206 )
3207 cover_target = _create_cover_layout(
3208 extract_dir, master_part, base_layout_part
3209 )
3210 for slide_num in minority_slides:
3211 rels_path = (
3212 extract_dir / "ppt" / "slides" / "_rels"
3213 / f"slide{slide_num}.xml.rels"
3214 )
3215 _set_slide_layout_target(rels_path, cover_target)
3216 if verbose:
3217 print(
3218 " Baseline cover layout: "
3219 f"{len(minority_slides)} slide(s) keep slide-local chrome"
3220 )
3221
3222 if verbose and promoted:
3223 print(
3224 " Baseline master chrome: "
3225 f"promoted {promoted} slide shape(s) across {promoted_roles} shared object(s)"
3226 )
3227 return promoted
3228
3229
3230 _PAGE_NUMBER_TOKENS = {"pagenumber", "pagenum", "slidenumber"}
3231
3232
3233 def _first_slide_number(extract_dir: Path) -> int:
3234 """Read firstSlideNum from presentation.xml (defaults to 1)."""
3235 presentation_path = extract_dir / "ppt" / "presentation.xml"
3236 try:
3237 root = ET.parse(presentation_path).getroot()
3238 except (OSError, ET.ParseError):
3239 return 1
3240 raw = root.attrib.get("firstSlideNum")
3241 if raw is None:
3242 return 1
3243 try:
3244 return int(raw)
3245 except ValueError:
3246 return 1
3247
3248
3249 def _shape_with_id(root: ET.Element, shape_id: str) -> ET.Element | None:
3250 """Find a p:sp anywhere in the slide tree by its cNvPr id."""
3251 for shape in root.iter(f"{{{PML_NS}}}sp"):
3252 cnv = shape.find(f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}cNvPr")
3253 if cnv is not None and cnv.attrib.get("id") == shape_id:
3254 return shape
3255 return None
3256
3257
3258 def _replace_literal_run_with_slidenum_field(
3259 shape: ET.Element,
3260 expected_text: str,
3261 field_guid: str,
3262 ) -> bool:
3263 """Swap a single literal page-number run for an a:fld slidenum field."""
3264 tx_body = shape.find(f"{{{PML_NS}}}txBody")
3265 if tx_body is None:
3266 return False
3267 a_t = f"{{{DML_NS}}}t"
3268 total_text = "".join(t.text or "" for t in tx_body.iter(a_t))
3269 if total_text.strip() != expected_text:
3270 return False
3271 text_runs = [
3272 (paragraph, run)
3273 for paragraph in tx_body.iter(f"{{{DML_NS}}}p")
3274 for run in paragraph.findall(f"{{{DML_NS}}}r")
3275 if (run.findtext(a_t) or "").strip()
3276 ]
3277 if len(text_runs) != 1:
3278 return False
3279 paragraph, run = text_runs[0]
3280 if (run.findtext(a_t) or "").strip() != expected_text:
3281 return False
3282
3283 fld = ET.Element(f"{{{DML_NS}}}fld", {"id": field_guid, "type": "slidenum"})
3284 r_pr = run.find(f"{{{DML_NS}}}rPr")
3285 if r_pr is not None:
3286 fld.append(ET.fromstring(ET.tostring(r_pr, encoding="utf-8")))
3287 fld_text = ET.SubElement(fld, a_t)
3288 fld_text.text = expected_text
3289 index = list(paragraph).index(run)
3290 paragraph.remove(run)
3291 paragraph.insert(index, fld)
3292 return True
3293
3294
3295 def _convert_page_number_texts_to_fields(
3296 extract_dir: Path,
3297 slide_count: int,
3298 conversion_traces: list[dict[str, Any]] | None,
3299 *,
3300 context: str = "Baseline",
3301 verbose: bool = False,
3302 ) -> int:
3303 """Replace literal page-number chrome text with auto-updating fields.
3304
3305 Only converts when the traced pageNumber/slideNumber shape's whole text
3306 equals the slide's expected display number (honoring firstSlideNum), so
3307 schemes like content-only numbering keep their literal text untouched.
3308 """
3309 if not conversion_traces:
3310 return 0
3311 trace_by_slide = {
3312 int(trace.get("slide_num", 0)): trace
3313 for trace in conversion_traces
3314 if trace.get("slide_num") is not None
3315 }
3316 first_slide_number = _first_slide_number(extract_dir)
3317 field_guid = f"{{{str(uuid.uuid4()).upper()}}}"
3318
3319 converted = 0
3320 for slide_num in range(1, slide_count + 1):
3321 tokens = _trace_chrome_shape_ids(trace_by_slide.get(slide_num))
3322 shape_ids = sorted({
3323 shape_id
3324 for token, ids in tokens.items()
3325 if token in _PAGE_NUMBER_TOKENS
3326 for shape_id in ids
3327 })
3328 if len(shape_ids) != 1:
3329 continue
3330 slide_path = extract_dir / "ppt" / "slides" / f"slide{slide_num}.xml"
3331 tree = ET.parse(slide_path)
3332 shape = _shape_with_id(tree.getroot(), shape_ids[0])
3333 if shape is None:
3334 continue
3335 expected_text = str(first_slide_number + slide_num - 1)
3336 if _replace_literal_run_with_slidenum_field(shape, expected_text, field_guid):
3337 _write_xml_tree(slide_path, tree)
3338 converted += 1
3339
3340 if verbose and converted:
3341 print(
3342 f" {context} slide-number fields: "
3343 f"converted {converted} page number(s)"
3344 )
3345 return converted
3346
3347
3348 def _remove_relationship(rels_path: Path, rel_id: str) -> None:
3349 """Remove one relationship entry by rId."""
3350 rels_content = rels_path.read_text(encoding="utf-8")
3351 pattern = re.compile(
3352 rf'[ \t]*<Relationship\b[^>]*\bId="{re.escape(rel_id)}"[^>]*/>[ \t]*\n?'
3353 )
3354 new_content, removed = pattern.subn("", rels_content, count=1)
3355 if not removed:
3356 raise RuntimeError(f"Relationship {rel_id} not found in {rels_path}")
3357 rels_path.write_text(new_content, encoding="utf-8")
3358
3359
3360 def _remove_content_type_override(content_types_path: Path, part_name: str) -> None:
3361 """Remove the Override content-type entry for a deleted package part."""
3362 normalized = "/" + part_name.lstrip("/")
3363 content = content_types_path.read_text(encoding="utf-8")
3364 pattern = re.compile(
3365 rf'[ \t]*<Override\b[^>]*\bPartName="{re.escape(normalized)}"[^>]*/>[ \t]*\n?'
3366 )
3367 new_content, removed = pattern.subn("", content, count=1)
3368 if removed:
3369 content_types_path.write_text(new_content, encoding="utf-8")
3370
3371
3372 def _remove_trailing_layout_definition_slides(
3373 extract_dir: Path,
3374 public_slide_count: int,
3375 total_slide_count: int,
3376 ) -> int:
3377 """Remove internal carrier slides after their Layouts are registered."""
3378 if total_slide_count <= public_slide_count:
3379 return 0
3380 presentation_part = "ppt/presentation.xml"
3381 presentation_path = extract_dir / presentation_part
3382 presentation_rels = _relationships_path_for_part(
3383 extract_dir,
3384 presentation_part,
3385 )
3386 tree = ET.parse(presentation_path)
3387 root = tree.getroot()
3388 slide_list = root.find(f"{{{PML_NS}}}sldIdLst")
3389 if slide_list is None:
3390 raise RuntimeError("presentation.xml has no p:sldIdLst")
3391 entries_by_rel_id = {
3392 entry.get(f"{{{REL_NS}}}id", ""): entry
3393 for entry in slide_list.findall(f"{{{PML_NS}}}sldId")
3394 }
3395 relationships = _read_relationships(presentation_rels)
3396 content_types_path = extract_dir / "[Content_Types].xml"
3397 removed = 0
3398 for slide_num in range(public_slide_count + 1, total_slide_count + 1):
3399 slide_part = f"ppt/slides/slide{slide_num}.xml"
3400 rel_ids = [
3401 rel_id
3402 for rel_id, attrs in relationships.items()
3403 if attrs.get("Type") == SLIDE_REL_TYPE
3404 and _resolve_package_target(
3405 presentation_part,
3406 attrs.get("Target", ""),
3407 ) == slide_part
3408 ]
3409 if len(rel_ids) != 1:
3410 raise RuntimeError(
3411 f"Internal Layout carrier {slide_part} must have exactly one "
3412 "Presentation relationship"
3413 )
3414 rel_id = rel_ids[0]
3415 entry = entries_by_rel_id.get(rel_id)
3416 if entry is None:
3417 raise RuntimeError(
3418 f"presentation.xml has no p:sldId entry for {slide_part}"
3419 )
3420 slide_list.remove(entry)
3421 _remove_relationship(presentation_rels, rel_id)
3422 slide_path = extract_dir / slide_part
3423 slide_rels = _relationships_path_for_part(extract_dir, slide_part)
3424 if not slide_path.is_file() or not slide_rels.is_file():
3425 raise RuntimeError(
3426 f"Internal Layout carrier package parts are incomplete: {slide_part}"
3427 )
3428 slide_path.unlink()
3429 slide_rels.unlink()
3430 _remove_content_type_override(content_types_path, slide_part)
3431 removed += 1
3432 _write_xml_tree(presentation_path, tree)
3433 return removed
3434
3435
3436 def _prune_unreferenced_definition_payload_parts(extract_dir: Path) -> int:
3437 """Remove generated native/media payload left by deleted carrier slides.
3438
3439 Definition-only SVGs are first converted as ordinary slides so their
3440 reusable structure can be promoted. Removing those internal slides may
3441 leave chart, workbook, or media parts with no remaining relationship. Run
3442 an iterative incoming-reference sweep so chart-owned workbooks/styles are
3443 removed after their orphan chart part and relationship sidecar disappear.
3444 """
3445 candidate_prefixes = (
3446 "ppt/charts/",
3447 "ppt/embeddings/",
3448 "ppt/media/",
3449 )
3450 content_types_path = extract_dir / "[Content_Types].xml"
3451 removed = 0
3452 while True:
3453 referenced_parts: set[str] = set()
3454 for rels_path in extract_dir.rglob("*.rels"):
3455 rels_rel = rels_path.relative_to(extract_dir).as_posix()
3456 try:
3457 root = ET.parse(rels_path).getroot()
3458 except ET.ParseError as exc:
3459 raise RuntimeError(
3460 f"Invalid relationships XML while pruning {rels_rel}: {exc}"
3461 ) from exc
3462 for elem in root:
3463 attrs = _relationship_attrs(elem)
3464 if attrs.get("TargetMode", "").lower() == "external":
3465 continue
3466 target = attrs.get("Target")
3467 if not target:
3468 continue
3469 resolved = _resolve_internal_opc_target(rels_rel, target)
3470 if resolved is not None:
3471 referenced_parts.add(resolved)
3472
3473 orphan_paths: list[tuple[Path, str]] = []
3474 for path in extract_dir.rglob("*"):
3475 if not path.is_file():
3476 continue
3477 part_name = path.relative_to(extract_dir).as_posix()
3478 if "/_rels/" in part_name:
3479 continue
3480 if not part_name.startswith(candidate_prefixes):
3481 continue
3482 canonical = _canonical_opc_part_path(part_name)
3483 if canonical is not None and canonical not in referenced_parts:
3484 orphan_paths.append((path, part_name))
3485 if not orphan_paths:
3486 break
3487
3488 for path, part_name in orphan_paths:
3489 rels_path = _relationships_path_for_part(extract_dir, part_name)
3490 if rels_path.is_file():
3491 rels_path.unlink()
3492 path.unlink()
3493 _remove_content_type_override(content_types_path, part_name)
3494 removed += 1
3495 return removed
3496
3497
3498 def _prune_unused_slide_layouts(
3499 extract_dir: Path,
3500 structure: PptxStructureContext,
3501 slide_count: int,
3502 *,
3503 verbose: bool = False,
3504 ) -> int:
3505 """Remove base-template slide layouts no generated slide references.
3506
3507 The python-pptx base package ships the full Office layout set; unused
3508 entries only pollute the PowerPoint new-slide picker. Layouts referenced
3509 by any generated slide are always kept, and a master keeps its layout
3510 list untouched unless at least one referenced layout remains in it.
3511 """
3512 # Read layout references live: earlier baseline passes may have rebound
3513 # minority slides to a Cover layout that the initial structure context
3514 # does not know about.
3515 referenced_layouts: set[str] = set()
3516 for slide_num in range(1, slide_count + 1):
3517 rels_path = (
3518 extract_dir / "ppt" / "slides" / "_rels" / f"slide{slide_num}.xml.rels"
3519 )
3520 target = _find_relationship_target(rels_path, SLIDE_LAYOUT_REL_TYPE)
3521 if not target:
3522 raise RuntimeError(f"Slide {slide_num} has no slide layout relationship")
3523 referenced_layouts.add(
3524 _resolve_package_target(f"ppt/slides/slide{slide_num}.xml", target)
3525 )
3526
3527 pruned = 0
3528 content_types_path = extract_dir / "[Content_Types].xml"
3529 for master_part in sorted(set(structure.slide_master_parts.values())):
3530 master_path = extract_dir / master_part
3531 master_rels_path = _relationships_path_for_part(extract_dir, master_part)
3532 layout_rels = {
3533 rel_id: _resolve_package_target(master_part, attrs.get("Target", ""))
3534 for rel_id, attrs in _read_relationships(master_rels_path).items()
3535 if attrs.get("Type") == SLIDE_LAYOUT_REL_TYPE
3536 }
3537 if not any(part in referenced_layouts for part in layout_rels.values()):
3538 continue
3539
3540 master_xml = master_path.read_text(encoding="utf-8")
3541 for rel_id, layout_part in sorted(layout_rels.items()):
3542 if layout_part in referenced_layouts:
3543 continue
3544 entry_re = re.compile(
3545 rf'[ \t]*<p:sldLayoutId\b[^>]*\br:id="{re.escape(rel_id)}"[^>]*/>[ \t]*\n?'
3546 )
3547 master_xml, removed = entry_re.subn("", master_xml, count=1)
3548 if not removed:
3549 raise RuntimeError(
3550 f"Slide master {master_part} has no sldLayoutId entry for {rel_id}"
3551 )
3552 _remove_relationship(master_rels_path, rel_id)
3553 (extract_dir / layout_part).unlink()
3554 layout_rels_path = _relationships_path_for_part(extract_dir, layout_part)
3555 if layout_rels_path.exists():
3556 layout_rels_path.unlink()
3557 _remove_content_type_override(content_types_path, layout_part)
3558 pruned += 1
3559 master_path.write_text(master_xml, encoding="utf-8")
3560
3561 if verbose and pruned:
3562 print(f" Layout prune: removed {pruned} unused base layout(s)")
3563 return pruned
3564
3565
3566 def _flat_structure_name(value: str | None) -> str:
3567 """Return one compact package identity for a free-design deck."""
3568 normalized = " ".join((value or "").split()).strip()
3569 return (normalized or "Free Design")[:120]
3570
3571
3572 def _flat_placeholder_type(shape: ET.Element) -> str | None:
3573 """Return one system placeholder type carried by a top-level shape."""
3574 if shape.tag != f"{{{PML_NS}}}sp":
3575 return None
3576 placeholder = shape.find(
3577 f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
3578 )
3579 return placeholder.get("type") if placeholder is not None else None
3580
3581
3582 def _clean_flat_structure_part(
3583 part_path: Path,
3584 name: str,
3585 *,
3586 is_layout: bool,
3587 ) -> tuple[int, tuple[str, ...]]:
3588 """Keep only standard footer hooks in one project-owned flat shell."""
3589 try:
3590 tree = ET.parse(part_path)
3591 except (OSError, ET.ParseError) as exc:
3592 raise RuntimeError(
3593 f"Cannot parse flat structure part {part_path}: {exc}"
3594 ) from exc
3595 root = tree.getroot()
3596 common_slide = root.find(f"{{{PML_NS}}}cSld")
3597 if common_slide is None:
3598 raise RuntimeError(f"Flat structure part has no p:cSld: {part_path}")
3599 shape_tree = common_slide.find(f"{{{PML_NS}}}spTree")
3600 if shape_tree is None:
3601 raise RuntimeError(f"Flat structure part has no p:spTree: {part_path}")
3602
3603 removed = 0
3604 retained: list[str] = []
3605 for child in list(shape_tree):
3606 if child.tag not in _TOP_LEVEL_SHAPE_TAGS:
3607 continue
3608 placeholder_type = _flat_placeholder_type(child)
3609 if placeholder_type in _FLAT_SYSTEM_PLACEHOLDER_TYPES:
3610 retained.append(placeholder_type)
3611 continue
3612 shape_tree.remove(child)
3613 removed += 1
3614 for parent in (common_slide, root):
3615 for extension_list in parent.findall(f"{{{PML_NS}}}extLst"):
3616 parent.remove(extension_list)
3617
3618 common_slide.set("name", name)
3619 if is_layout:
3620 root.set("type", "blank")
3621 root.set("preserve", "1")
3622 _write_xml_tree(part_path, tree)
3623 return removed, tuple(retained)
3624
3625
3626 def _name_flat_themes(extract_dir: Path, name: str) -> int:
3627 """Replace stock Office theme identities with the current deck identity."""
3628 theme_paths = sorted((extract_dir / "ppt" / "theme").glob("theme*.xml"))
3629 if not theme_paths:
3630 raise RuntimeError("Flat PPTX package has no theme part")
3631 for theme_path in theme_paths:
3632 try:
3633 tree = ET.parse(theme_path)
3634 except (OSError, ET.ParseError) as exc:
3635 raise RuntimeError(
3636 f"Cannot parse flat theme {theme_path}: {exc}"
3637 ) from exc
3638 root = tree.getroot()
3639 root.set("name", name)
3640 for tag in ("clrScheme", "fontScheme", "fmtScheme"):
3641 scheme = root.find(f".//{{{DML_NS}}}{tag}")
3642 if scheme is not None:
3643 scheme.set("name", name)
3644 _write_xml_tree(theme_path, tree)
3645 return len(theme_paths)
3646
3647
3648 def _prepare_flat_structure(
3649 extract_dir: Path,
3650 structure: PptxStructureContext,
3651 slide_count: int,
3652 master_text_style_spec: MasterTextStyleSpec | None,
3653 structure_name: str | None,
3654 *,
3655 verbose: bool = False,
3656 ) -> None:
3657 """Materialize one clean current-deck Master and Blank Layout for flat export."""
3658 pruned = _prune_unused_slide_layouts(
3659 extract_dir,
3660 structure,
3661 slide_count,
3662 verbose=False,
3663 )
3664 live_structure = _read_slide_layout_targets(extract_dir, slide_count)
3665 layout_parts = {
3666 _resolve_package_target(
3667 f"ppt/slides/slide{slide_num}.xml",
3668 live_structure.slide_layout_target(slide_num),
3669 )
3670 for slide_num in range(1, slide_count + 1)
3671 }
3672 master_parts = set(live_structure.slide_master_parts.values())
3673 physical_layouts = {
3674 str(path.relative_to(extract_dir)).replace("\\", "/")
3675 for path in (extract_dir / "ppt" / "slideLayouts").glob("slideLayout*.xml")
3676 }
3677 physical_masters = {
3678 str(path.relative_to(extract_dir)).replace("\\", "/")
3679 for path in (extract_dir / "ppt" / "slideMasters").glob("slideMaster*.xml")
3680 }
3681 if len(layout_parts) != 1 or layout_parts != physical_layouts:
3682 raise RuntimeError(
3683 "Flat export must retain exactly one slide-referenced Blank Layout"
3684 )
3685 if len(master_parts) != 1 or master_parts != physical_masters:
3686 raise RuntimeError(
3687 "Flat export must retain exactly one slide-referenced Master"
3688 )
3689
3690 identity = _flat_structure_name(structure_name)
3691 theme_name = identity
3692 master_name = f"{identity} — Master"
3693 layout_name = f"{identity} — Blank"
3694 master_path = extract_dir / next(iter(master_parts))
3695 layout_path = extract_dir / next(iter(layout_parts))
3696 removed_master_shapes, retained_master_placeholders = _clean_flat_structure_part(
3697 master_path,
3698 master_name,
3699 is_layout=False,
3700 )
3701 removed_layout_shapes, retained_layout_placeholders = _clean_flat_structure_part(
3702 layout_path,
3703 layout_name,
3704 is_layout=True,
3705 )
3706 theme_count = _name_flat_themes(extract_dir, theme_name)
3707 master_count = (
3708 apply_master_text_style_spec(extract_dir, master_text_style_spec)
3709 if master_text_style_spec is not None
3710 else 0
3711 )
3712
3713 for part_path, expected_name, expected_layout in (
3714 (master_path, master_name, False),
3715 (layout_path, layout_name, True),
3716 ):
3717 root = ET.parse(part_path).getroot()
3718 common_slide = root.find(f"{{{PML_NS}}}cSld")
3719 if common_slide is None or common_slide.get("name") != expected_name:
3720 raise RuntimeError(
3721 f"Flat structure identity read-back failed: {part_path}"
3722 )
3723 shape_tree = common_slide.find(f"{{{PML_NS}}}spTree")
3724 if shape_tree is None:
3725 raise RuntimeError(f"Flat structure shell has no shape tree: {part_path}")
3726 actual_placeholder_types = tuple(
3727 sorted(
3728 _flat_placeholder_type(child) or ""
3729 for child in shape_tree
3730 if child.tag in _TOP_LEVEL_SHAPE_TAGS
3731 )
3732 )
3733 expected_placeholder_types = tuple(
3734 sorted(_FLAT_SYSTEM_PLACEHOLDER_TYPES)
3735 )
3736 if actual_placeholder_types != expected_placeholder_types:
3737 raise RuntimeError(
3738 "Flat structure shell must retain only one each of the date, "
3739 f"footer, and slide-number hooks: {part_path}"
3740 )
3741 if expected_layout and root.get("type") != "blank":
3742 raise RuntimeError(f"Flat layout is not typed as Blank: {part_path}")
3743
3744 if verbose:
3745 print(
3746 " Flat structure: project-owned Master + Blank Layout "
3747 f"({pruned} stock layout(s), "
3748 f"{removed_master_shapes + removed_layout_shapes} stock content "
3749 "shape(s) removed, "
3750 f"{len(retained_master_placeholders) + len(retained_layout_placeholders)} "
3751 "system footer hook(s) retained)"
3752 )
3753 text_style_status = (
3754 f"{master_count} master text style(s)"
3755 if master_text_style_spec is not None
3756 else "stock text defaults retained (no theme contract)"
3757 )
3758 print(f" Flat theme: {theme_count} theme part(s), {text_style_status}")
3759
3760
3761 def _append_relationship(
3762 rels_path: Path,
3763 rel_type: str,
3764 target: str,
3765 ) -> str:
3766 """Append a relationship entry with the next available rId."""
3767 with open(rels_path, 'r', encoding='utf-8') as f:
3768 rels_content = f.read()
3769
3770 rid_numbers = [int(match) for match in re.findall(r'Id="rId(\d+)"', rels_content)]
3771 next_rid = f'rId{max(rid_numbers, default=0) + 1}'
3772 rel_xml = (
3773 f' <Relationship Id="{next_rid}" '
3774 f'Type="{rel_type}" Target="{target}"/>'
3775 )
3776 rels_content = rels_content.replace(
3777 '</Relationships>', rel_xml + '\n</Relationships>',
3778 )
3779
3780 with open(rels_path, 'w', encoding='utf-8') as f:
3781 f.write(rels_content)
3782
3783 return next_rid
3784
3785
3786 def _add_default_content_type(content_types: str, extension: str, content_type: str) -> str:
3787 """Add a Default content type if it is not already present."""
3788 ext = extension.lstrip(".")
3789 if f'Extension="{ext}"' in content_types:
3790 return content_types
3791 entry = f' <Default Extension="{ext}" ContentType="{content_type}"/>'
3792 override_pos = content_types.find('<Override ')
3793 if override_pos >= 0:
3794 return content_types[:override_pos] + entry + '\n' + content_types[override_pos:]
3795 return content_types.replace('</Types>', entry + '\n</Types>')
3796
3797
3798 def _add_content_type_override(content_types: str, part_name: str, content_type: str) -> str:
3799 """Add an Override content type if it is not already present."""
3800 normalized = '/' + part_name.lstrip('/')
3801 if f'PartName="{normalized}"' in content_types:
3802 return content_types
3803 entry = f' <Override PartName="{normalized}" ContentType="{content_type}"/>'
3804 return content_types.replace('</Types>', entry + '\n</Types>')
3805
3806
3807 _IMAGE_CONTENT_TYPES = {
3808 'png': 'image/png',
3809 'jpg': 'image/jpeg',
3810 'jpeg': 'image/jpeg',
3811 'gif': 'image/gif',
3812 'webp': 'image/webp',
3813 'svg': 'image/svg+xml',
3814 'bmp': 'image/bmp',
3815 'emf': 'image/x-emf',
3816 'tif': 'image/tiff',
3817 'tiff': 'image/tiff',
3818 'wmf': 'image/x-wmf',
3819 }
3820
3821
3822 def _content_type_for_extension(ext: str) -> str:
3823 clean = ext.lower().lstrip('.')
3824 content_type = _IMAGE_CONTENT_TYPES.get(clean) or mimetypes.guess_type(f'x.{clean}')[0]
3825 if not content_type:
3826 raise ValueError(f"Unknown media content type for extension: {ext}")
3827 return content_type
3828
3829
3830 def _as_dict(value: Any) -> dict[str, Any]:
3831 return value if isinstance(value, dict) else {}
3832
3833
3834 def _create_writable_work_dir(output_path: Path) -> Path:
3835 """Create a real writable work directory for PPTX assembly."""
3836 parents = [output_path.parent, Path.cwd(), Path(tempfile.gettempdir())]
3837 seen: set[str] = set()
3838 errors: list[str] = []
3839
3840 for parent in parents:
3841 parent = parent if str(parent) else Path(".")
3842 try:
3843 key = str(parent.resolve())
3844 except OSError:
3845 key = str(parent.absolute())
3846 if key in seen:
3847 continue
3848 seen.add(key)
3849
3850 try:
3851 parent.mkdir(parents=True, exist_ok=True)
3852 except OSError as exc:
3853 errors.append(f"{parent}: cannot create parent ({exc})")
3854 continue
3855
3856 for _ in range(3):
3857 work_dir = parent / f".pptx-build-{os.getpid()}-{uuid.uuid4().hex}"
3858 try:
3859 work_dir.mkdir(mode=0o700)
3860 probe_path = work_dir / ".write-probe"
3861 probe_path.write_text("ok", encoding="utf-8")
3862 probe_path.unlink()
3863 return work_dir
3864 except OSError as exc:
3865 errors.append(f"{work_dir}: {exc}")
3866 shutil.rmtree(work_dir, ignore_errors=True)
3867
3868 details = "\n - ".join(errors) if errors else "no candidate directories available"
3869 raise PermissionError(
3870 "Unable to create a writable PPTX work directory. "
3871 "Set the output path to a writable project directory or adjust sandbox permissions. "
3872 f"Tried:\n - {details}"
3873 )
3874
3875
3876 def _relax_output_permissions(output_path: Path) -> list[str]:
3877 """Make exported files readable outside the sandbox owner where possible."""
3878 warnings: list[str] = []
3879
3880 try:
3881 current_mode = output_path.stat().st_mode
3882 readable_mode = (
3883 current_mode
3884 | stat.S_IRUSR
3885 | stat.S_IWUSR
3886 | stat.S_IRGRP
3887 | stat.S_IROTH
3888 )
3889 os.chmod(output_path, readable_mode)
3890 except OSError as exc:
3891 warnings.append(f"chmod skipped for {output_path}: {exc}")
3892
3893 if os.name != 'nt':
3894 return warnings
3895
3896 # Windows ACLs can remain sandbox-only even when the file mode looks sane.
3897 # Grant the built-in Users SID read access; the SID avoids localization
3898 # issues on non-English Windows installations.
3899 try:
3900 result = subprocess.run(
3901 ['icacls', str(output_path), '/grant', '*S-1-5-32-545:R'],
3902 capture_output=True,
3903 text=True,
3904 check=False,
3905 )
3906 except OSError as exc:
3907 warnings.append(f"icacls skipped for {output_path}: {exc}")
3908 else:
3909 if result.returncode != 0:
3910 message = (result.stderr or result.stdout or '').strip()
3911 details = f": {message}" if message else ''
3912 warnings.append(f"icacls failed for {output_path}{details}")
3913
3914 return warnings
3915
3916
3917 _NOTES_MASTER_REL_TYPE = (
3918 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster'
3919 )
3920
3921
3922 def _ensure_notes_master(
3923 extract_dir: Path,
3924 primary_language: str | None = None,
3925 ) -> None:
3926 """Create notesMaster parts and wire them into the presentation package."""
3927 ppt_dir = extract_dir / 'ppt'
3928 notes_masters_dir = ppt_dir / 'notesMasters'
3929 notes_masters_dir.mkdir(exist_ok=True)
3930
3931 notes_master_path = notes_masters_dir / 'notesMaster1.xml'
3932 if not notes_master_path.exists():
3933 notes_master_path.write_text(
3934 create_notes_master_xml(primary_language),
3935 encoding='utf-8',
3936 )
3937
3938 theme_dir = ppt_dir / 'theme'
3939 theme_dir.mkdir(exist_ok=True)
3940 theme1_path = theme_dir / 'theme1.xml'
3941 theme2_path = theme_dir / 'theme2.xml'
3942 if not theme2_path.exists():
3943 if theme1_path.exists():
3944 shutil.copy2(theme1_path, theme2_path)
3945 else:
3946 raise RuntimeError('Cannot create notes theme: ppt/theme/theme1.xml is missing')
3947
3948 notes_master_rels_dir = notes_masters_dir / '_rels'
3949 notes_master_rels_dir.mkdir(exist_ok=True)
3950 notes_master_rels_path = notes_master_rels_dir / 'notesMaster1.xml.rels'
3951 if not notes_master_rels_path.exists():
3952 notes_master_rels_path.write_text(
3953 create_notes_master_rels_xml(),
3954 encoding='utf-8',
3955 )
3956
3957 presentation_rels_path = ppt_dir / '_rels' / 'presentation.xml.rels'
3958 notes_master_rid = _find_relationship_id(
3959 presentation_rels_path,
3960 _NOTES_MASTER_REL_TYPE,
3961 'notesMasters/notesMaster1.xml',
3962 )
3963 if notes_master_rid is None:
3964 notes_master_rid = _append_relationship(
3965 presentation_rels_path,
3966 _NOTES_MASTER_REL_TYPE,
3967 'notesMasters/notesMaster1.xml',
3968 )
3969
3970 presentation_path = ppt_dir / 'presentation.xml'
3971 presentation_xml = presentation_path.read_text(encoding='utf-8')
3972 if '<p:notesMasterIdLst>' in presentation_xml:
3973 return
3974 notes_master_lst = (
3975 f'<p:notesMasterIdLst><p:notesMasterId r:id="{notes_master_rid}"/>'
3976 '</p:notesMasterIdLst>'
3977 )
3978 if '</p:sldMasterIdLst>' not in presentation_xml:
3979 raise RuntimeError('presentation.xml is missing p:sldMasterIdLst')
3980 presentation_xml = presentation_xml.replace(
3981 '</p:sldMasterIdLst>',
3982 '</p:sldMasterIdLst>' + notes_master_lst,
3983 1,
3984 )
3985 presentation_path.write_text(presentation_xml, encoding='utf-8')
3986
3987
3988 def _slide_config(animation_config: dict[str, Any] | None, svg_stem: str) -> dict[str, Any]:
3989 if not animation_config:
3990 return {}
3991 slides_value = animation_config.get('slides', {})
3992 if not isinstance(slides_value, dict):
3993 raise ValueError('animations.json field "slides" must be an object')
3994 slide_value = slides_value.get(svg_stem, {})
3995 if not isinstance(slide_value, dict):
3996 raise ValueError(
3997 f'animations.json slide "{svg_stem}" must be an object'
3998 )
3999 return slide_value
4000
4001
4002 def _slide_transition_settings(
4003 slide_cfg: dict[str, Any],
4004 transition: str | None,
4005 transition_effect_options: dict[str, object] | None,
4006 duration: float,
4007 auto_advance: float | None,
4008 cli_overrides: dict[str, bool],
4009 ) -> tuple[str | None, dict[str, object], float, float | None]:
4010 trans_value = slide_cfg.get('transition', {})
4011 if not isinstance(trans_value, dict):
4012 raise ValueError('animations.json slide transition must be an object')
4013 trans_cfg = trans_value
4014 effect, effect_options = normalize_transition_effect_request(
4015 transition,
4016 transition_effect_options,
4017 )
4018 if not cli_overrides.get('transition'):
4019 if 'effect' in trans_cfg:
4020 raw_effect = trans_cfg['effect']
4021 raw_options = trans_cfg.get('effect_options')
4022 effect, effect_options = normalize_transition_effect_request(
4023 raw_effect,
4024 raw_options,
4025 )
4026 elif 'effect_options' in trans_cfg:
4027 raise ValueError(
4028 'animations.json transition effect_options requires '
4029 'an explicit effect'
4030 )
4031 if not cli_overrides.get('transition_duration'):
4032 if 'duration' in trans_cfg:
4033 duration = validate_seconds(
4034 trans_cfg.get('duration'),
4035 "transition duration",
4036 allow_zero=effect is None,
4037 )
4038 if not cli_overrides.get('auto_advance') and 'auto_advance' in trans_cfg:
4039 auto_advance = validate_seconds(
4040 trans_cfg.get('auto_advance'),
4041 "transition auto_advance",
4042 allow_zero=True,
4043 )
4044 return effect, effect_options, duration, auto_advance
4045
4046
4047 def _slide_animation_settings(
4048 slide_cfg: dict[str, Any],
4049 default_animation_cfg: dict[str, Any],
4050 animation: str | None,
4051 duration: float,
4052 stagger: float,
4053 trigger: str,
4054 cli_overrides: dict[str, bool],
4055 ) -> tuple[str | None, float, float, str, dict[str, Any]]:
4056 anim_value = slide_cfg.get('animation', {})
4057 if not isinstance(anim_value, dict):
4058 raise ValueError('animations.json slide animation must be an object')
4059 anim_cfg = anim_value
4060 resolved_cfg = resolve_slide_animation_config(
4061 default_animation_cfg,
4062 anim_cfg,
4063 )
4064 if cli_overrides.get('animation'):
4065 effect, effect_options = normalize_animation_effect_request(
4066 animation,
4067 allow_none=True,
4068 allow_modes=True,
4069 )
4070 resolved_cfg['effect'] = effect or 'none'
4071 if effect_options:
4072 resolved_cfg['effect_options'] = effect_options
4073 else:
4074 resolved_cfg.pop('effect_options', None)
4075 else:
4076 raw_effect = resolved_cfg.get('effect', animation)
4077 effect, effect_options = normalize_animation_effect_request(
4078 raw_effect,
4079 resolved_cfg.get('effect_options'),
4080 allow_none=True,
4081 allow_modes=True,
4082 )
4083 resolved_cfg['effect'] = effect or 'none'
4084 if effect_options:
4085 resolved_cfg['effect_options'] = effect_options
4086 else:
4087 resolved_cfg.pop('effect_options', None)
4088 if not cli_overrides.get('animation_duration'):
4089 duration = validate_seconds(
4090 anim_cfg.get('duration', duration),
4091 'animation duration',
4092 allow_zero=False,
4093 )
4094 else:
4095 resolved_cfg['duration'] = duration
4096 if not cli_overrides.get('animation_stagger'):
4097 stagger = validate_seconds(
4098 anim_cfg.get('stagger', stagger),
4099 'animation stagger',
4100 allow_zero=True,
4101 )
4102 else:
4103 resolved_cfg['stagger'] = stagger
4104 if not cli_overrides.get('animation_trigger') and 'trigger' in anim_cfg:
4105 trigger = normalize_animation_trigger(anim_cfg.get('trigger'))
4106 else:
4107 trigger = normalize_animation_trigger(trigger)
4108 resolved_cfg['trigger'] = trigger
4109 animation_seconds_to_milliseconds(
4110 duration,
4111 'animation duration',
4112 allow_zero=False,
4113 )
4114 animation_seconds_to_milliseconds(
4115 stagger,
4116 'animation stagger',
4117 allow_zero=True,
4118 )
4119 resolved_cfg['effect'] = effect or 'none'
4120 resolved_cfg['duration'] = duration
4121 resolved_cfg['stagger'] = stagger
4122 resolved_cfg['trigger'] = trigger
4123 return effect, duration, stagger, trigger, resolved_cfg
4124
4125
4126 def _build_sequence_targets(
4127 anim_targets: list[tuple[int, str]],
4128 slide_name: str,
4129 slide_cfg: dict[str, Any],
4130 animation: str | None,
4131 animation_cfg: dict[str, Any],
4132 duration: float,
4133 stagger: float,
4134 mixed_animation_offset: int,
4135 animation_rng: random.Random,
4136 ) -> tuple[list[dict[str, Any]], int]:
4137 groups_value = slide_cfg.get('groups', {})
4138 if not isinstance(groups_value, dict):
4139 raise ValueError('animations.json slide groups must be an object')
4140 groups_cfg = groups_value
4141 shape_ids_by_group = {
4142 svg_id: sid for sid, svg_id in anim_targets
4143 }
4144 ordered: list[tuple[int, int, int, str, str, dict[str, Any]]] = []
4145 for idx, (sid, svg_id) in enumerate(anim_targets):
4146 group_value = groups_cfg.get(svg_id, {})
4147 if not isinstance(group_value, dict):
4148 raise ValueError(
4149 f'animations.json group "{svg_id}" must be an object'
4150 )
4151 group_path = (
4152 f'slides[{json.dumps(slide_name, ensure_ascii=False)}]'
4153 f'.groups[{json.dumps(svg_id, ensure_ascii=False)}]'
4154 )
4155 effect_entries = animation_group_effect_entries(
4156 group_value,
4157 path=group_path,
4158 )
4159 for effect_idx, (effect_path, effect_cfg) in enumerate(effect_entries):
4160 raw_effect = effect_cfg.get('effect')
4161 if raw_effect is not None:
4162 normalized_effect = normalize_animation_effect(
4163 raw_effect,
4164 allow_none=True,
4165 allow_modes=True,
4166 )
4167 else:
4168 normalized_effect = None
4169 if 'effect' in effect_cfg and normalized_effect is None:
4170 continue
4171 if animation is None and normalized_effect is None:
4172 continue
4173 order_value = effect_cfg.get('order')
4174 order = order_value if order_value is not None else idx + 1
4175 if (
4176 isinstance(order, bool)
4177 or not isinstance(order, int)
4178 or order <= 0
4179 ):
4180 raise ValueError(
4181 f'animations.json {effect_path}.order must be '
4182 'a positive integer'
4183 )
4184 effect_entry = dict(effect_cfg)
4185 effect_entry['_shape_id'] = sid
4186 effect_entry['_effect'] = normalized_effect
4187 effect_entry['_effect_raw'] = raw_effect
4188 ordered.append(
4189 (
4190 order,
4191 idx,
4192 effect_idx,
4193 svg_id,
4194 effect_path,
4195 effect_entry,
4196 )
4197 )
4198
4199 ordered.sort(key=lambda item: (item[0], item[1], item[2]))
4200
4201 seq_targets: list[dict[str, Any]] = []
4202 resolved_group_modes: list[str | None] = []
4203 main_sequence_count = 0
4204 for seq_idx, (
4205 _order,
4206 _original_idx,
4207 _effect_idx,
4208 _svg_id,
4209 effect_path,
4210 group_cfg,
4211 ) in enumerate(ordered):
4212 shape_id = int(group_cfg['_shape_id'])
4213 raw_effect = group_cfg.get('_effect')
4214 resolved_group_modes.append(
4215 raw_effect if raw_effect in ('auto', 'mixed', 'random') else None
4216 )
4217 if raw_effect in ('auto', 'mixed', 'random'):
4218 effect = pick_animation_effect(
4219 str(raw_effect), seq_idx, mixed_animation_offset, group_id=_svg_id,
4220 rng=animation_rng,
4221 )
4222 effect_options: dict[str, object] = {}
4223 else:
4224 effect = str(raw_effect or pick_animation_effect(
4225 animation, seq_idx, mixed_animation_offset, group_id=_svg_id,
4226 rng=animation_rng,
4227 ))
4228 request_effect = (
4229 group_cfg.get('_effect_raw')
4230 if group_cfg.get('_effect_raw') is not None
4231 else effect
4232 )
4233 option_value = (
4234 group_cfg.get('effect_options')
4235 if group_cfg.get('_effect_raw') is not None
4236 else animation_cfg.get('effect_options')
4237 )
4238 effect, effect_options = normalize_animation_effect_request(
4239 request_effect,
4240 option_value,
4241 allow_none=False,
4242 allow_modes=False,
4243 )
4244 item_duration = validate_seconds(
4245 group_cfg.get('duration', duration),
4246 f'animations.json {effect_path}.duration',
4247 allow_zero=False,
4248 )
4249 trigger_shape = group_cfg.get('trigger_shape')
4250 raw_trigger = group_cfg.get(
4251 'trigger',
4252 animation_cfg.get('trigger', 'after-previous'),
4253 )
4254 resolved_trigger = normalize_animation_trigger(raw_trigger)
4255 if trigger_shape is not None:
4256 if 'trigger' in group_cfg and resolved_trigger != 'on-click':
4257 raise ValueError(
4258 f'animations.json {effect_path}.trigger_shape requires '
4259 'trigger "on-click" when trigger is explicit'
4260 )
4261 resolved_trigger = 'on-click'
4262 default_delay = (
4263 stagger
4264 if (
4265 trigger_shape is None
4266 and resolved_trigger == 'after-previous'
4267 and main_sequence_count > 0
4268 )
4269 else 0
4270 )
4271 delay_seconds = validate_seconds(
4272 group_cfg.get('delay', default_delay),
4273 f'animations.json {effect_path}.delay',
4274 allow_zero=True,
4275 )
4276 delay_ms = animation_seconds_to_milliseconds(
4277 delay_seconds,
4278 f'animations.json {effect_path}.delay',
4279 allow_zero=True,
4280 )
4281 inherited_fields = {
4282 field: animation_cfg[field]
4283 for field in (
4284 *ANIMATION_TIMING_OPTION_FIELDS,
4285 'after_effect',
4286 'sound',
4287 )
4288 if field in animation_cfg
4289 }
4290 inherited_fields.update(
4291 {
4292 field: group_cfg[field]
4293 for field in (
4294 *ANIMATION_TIMING_OPTION_FIELDS,
4295 'after_effect',
4296 'sound',
4297 )
4298 if field in group_cfg
4299 }
4300 )
4301 target_entry: dict[str, Any] = {
4302 'shape_id': shape_id,
4303 'delay_ms': delay_ms,
4304 'effect': effect,
4305 'effect_options': effect_options,
4306 'duration': item_duration,
4307 'trigger': resolved_trigger,
4308 }
4309 if trigger_shape is not None:
4310 if not isinstance(trigger_shape, str) or not trigger_shape.strip():
4311 raise ValueError(
4312 f'animations.json {effect_path}.trigger_shape must '
4313 'be a non-empty group id'
4314 )
4315 trigger_shape_id = shape_ids_by_group.get(trigger_shape)
4316 if trigger_shape_id is None:
4317 raise ValueError(
4318 f'animations.json {effect_path}.trigger_shape '
4319 f'references a missing or non-triggerable group: '
4320 f'{trigger_shape}'
4321 )
4322 if trigger_shape_id == shape_id:
4323 raise ValueError(
4324 f'animations.json {effect_path}.trigger_shape must '
4325 'reference a different group'
4326 )
4327 target_entry['trigger_shape_id'] = trigger_shape_id
4328 else:
4329 main_sequence_count += 1
4330 target_entry.update(inherited_fields)
4331 if 'sound' in target_entry:
4332 target_entry['_sound_path'] = target_entry.pop('sound')
4333 seq_targets.append(target_entry)
4334
4335 mixed_count = 0
4336 if animation == 'mixed':
4337 mixed_count = sum(1 for _target in seq_targets[1:])
4338 elif animation == 'auto':
4339 # 'auto' accumulates a cross-slide offset so the image pool and the
4340 # unmatched-id fallback rotate as the deck advances. Single-effect
4341 # semantic matches (title→entrance_fade, chart→entrance_wipe, etc.)
4342 # are unaffected
4343 # because they ignore the offset.
4344 mixed_count = len(seq_targets)
4345 else:
4346 mixed_count = sum(
4347 1
4348 for seq_idx, mode in enumerate(resolved_group_modes)
4349 if mode == 'auto' or (mode == 'mixed' and seq_idx > 0)
4350 )
4351 return seq_targets, mixed_count
4352
4353
4354 def _next_relationship_id(rel_entries: list[dict[str, str]]) -> str:
4355 """Return the next slide relationship id, keeping rId1 for the layout."""
4356 used = {1}
4357 for rel in rel_entries:
4358 match = re.fullmatch(r'rId(\d+)', str(rel.get('id', '')))
4359 if match:
4360 used.add(int(match.group(1)))
4361 candidate = 2
4362 while candidate in used:
4363 candidate += 1
4364 return f'rId{candidate}'
4365
4366
4367 def _materialize_animation_sounds(
4368 project_path: Path,
4369 targets: list[dict[str, Any]],
4370 media_files: dict[str, bytes],
4371 rel_entries: list[dict[str, str]],
4372 audio_exts_used: set[str],
4373 ) -> list[dict[str, Any]]:
4374 """Package sidecar sound files and replace paths with OOXML relationships."""
4375 materialized: list[dict[str, Any]] = []
4376 packaged_by_source: dict[Path, tuple[str, str]] = {}
4377 for index, raw_target in enumerate(targets, 1):
4378 target = dict(raw_target)
4379 raw_sound = target.pop('_sound_path', None)
4380 if raw_sound is None:
4381 materialized.append(target)
4382 continue
4383 if not isinstance(raw_sound, str) or not raw_sound.strip():
4384 raise ValueError(
4385 f'animation target {index} sound must be a non-empty path string'
4386 )
4387 sound_path = Path(raw_sound)
4388 if not sound_path.is_absolute():
4389 sound_path = project_path / sound_path
4390 sound_path = sound_path.resolve()
4391 if not sound_path.is_file():
4392 raise ValueError(f'animation sound file not found: {sound_path}')
4393 extension = sound_path.suffix.lower()
4394 if extension not in AUDIO_CONTENT_TYPES:
4395 valid = ', '.join(sorted(AUDIO_CONTENT_TYPES))
4396 raise ValueError(
4397 f'unsupported animation sound format {extension or "(none)"}; '
4398 f'valid formats: {valid}'
4399 )
4400
4401 packaged = packaged_by_source.get(sound_path)
4402 if packaged is None:
4403 payload = sound_path.read_bytes()
4404 digest = hashlib.sha256(payload).hexdigest()[:16]
4405 media_name = f'animation_sound_{digest}{extension}'
4406 relationship_id = _next_relationship_id(rel_entries)
4407 media_files.setdefault(media_name, payload)
4408 rel_entries.append(
4409 {
4410 'id': relationship_id,
4411 'type': AUDIO_REL_TYPE,
4412 'target': f'../media/{media_name}',
4413 }
4414 )
4415 packaged = (relationship_id, media_name)
4416 packaged_by_source[sound_path] = packaged
4417 audio_exts_used.add(extension)
4418
4419 relationship_id, _media_name = packaged
4420 target['sound'] = {
4421 'relationship_id': relationship_id,
4422 'name': sound_path.name,
4423 }
4424 materialized.append(target)
4425 return materialized
4426
4427
4428 def _prerender_legacy_pngs(
4429 svg_files: list[Path],
4430 media_dir: Path,
4431 pixel_width: int,
4432 pixel_height: int,
4433 cache_dir: Path | None,
4434 workers: int,
4435 verbose: bool,
4436 ) -> dict[int, bool]:
4437 """Render every SVG→PNG into media_dir in parallel.
4438
4439 Returns {1-based slide index: success}. Falls back to sequential when
4440 workers<=1 or len(svg_files)<=2.
4441 """
4442 results: dict[int, bool] = {}
4443 targets: list[tuple[int, Path, Path]] = [
4444 (i, svg, media_dir / f'image{i}.png')
4445 for i, svg in enumerate(svg_files, 1)
4446 ]
4447
4448 if workers <= 1 or len(targets) <= 2:
4449 for i, svg, png in targets:
4450 ok = convert_svg_to_png_cached(svg, png, pixel_width, pixel_height, cache_dir)
4451 results[i] = ok
4452 if verbose:
4453 tag = 'cached/ok' if ok else 'failed'
4454 print(f" [PNG {i}/{len(targets)}] {svg.name} - {tag}")
4455 return results
4456
4457 with ProcessPoolExecutor(max_workers=workers) as pool:
4458 future_map = {
4459 pool.submit(
4460 convert_svg_to_png_cached,
4461 svg, png, pixel_width, pixel_height, cache_dir,
4462 ): (i, svg)
4463 for i, svg, png in targets
4464 }
4465 done = 0
4466 for future in as_completed(future_map):
4467 i, svg = future_map[future]
4468 try:
4469 ok = future.result()
4470 except Exception as exc:
4471 ok = False
4472 if verbose:
4473 print(f" [PNG] {svg.name} - worker error: {exc}")
4474 results[i] = ok
4475 done += 1
4476 if verbose:
4477 tag = 'cached/ok' if ok else 'failed'
4478 print(f" [PNG {done}/{len(targets)}] {svg.name} - {tag}")
4479
4480 return results
4481
4482
4483 def _presentation_format(width: float, height: float) -> str:
4484 """Map the slide aspect ratio to PowerPoint's PresentationFormat label.
4485 Non-standard ratios (square, portrait, banner crops) report 'Custom'.
4486 """
4487 if width <= 0 or height <= 0:
4488 return 'Custom'
4489 ratio = width / height
4490 for target, label in (
4491 (4 / 3, 'On-screen Show (4:3)'),
4492 (16 / 9, 'On-screen Show (16:9)'),
4493 (16 / 10, 'On-screen Show (16:10)'),
4494 ):
4495 if abs(ratio - target) < 0.02:
4496 return label
4497 return 'Custom'
4498
4499
4500 def _stamp_docprops(
4501 extract_dir: Path,
4502 slide_count: int,
4503 pres_format: str,
4504 meta: dict[str, Any] | None = None,
4505 ) -> None:
4506 """Overwrite the misleading python-pptx default metadata with accurate
4507 values. Factual fields (slide count, export timestamp, presentation format,
4508 application) are always machine-derived. Authored fields — including the
4509 title — come solely from an optional per-project ``metadata.json``
4510 (``meta``); whatever it omits stays blank. ``lastModifiedBy`` follows
4511 ``creator`` rather than ever carrying the base template's author or a tool
4512 name. No field is guessed from slide content: a blank title is preferable
4513 to an unreliable heuristic pick.
4514 """
4515 meta = meta or {}
4516
4517 def field(key: str, default: str = '') -> str:
4518 value = meta.get(key)
4519 return value.strip() if isinstance(value, str) and value.strip() else default
4520
4521 title = field('title')
4522 creator = field('creator')
4523
4524 now = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
4525
4526 core_path = extract_dir / 'docProps' / 'core.xml'
4527 if core_path.exists():
4528 core_path.write_text(
4529 "<?xml version='1.0' encoding='UTF-8' standalone='yes'?>\n"
4530 '<cp:coreProperties '
4531 'xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
4532 'xmlns:dc="http://purl.org/dc/elements/1.1/" '
4533 'xmlns:dcterms="http://purl.org/dc/terms/" '
4534 'xmlns:dcmitype="http://purl.org/dc/dcmitype/" '
4535 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">'
4536 f'<dc:title>{escape(title)}</dc:title>'
4537 f'<dc:subject>{escape(field("subject"))}</dc:subject>'
4538 f'<dc:creator>{escape(creator)}</dc:creator>'
4539 f'<cp:keywords>{escape(field("keywords"))}</cp:keywords>'
4540 f'<dc:description>{escape(field("description"))}</dc:description>'
4541 f'<dc:language>{escape(field("language"))}</dc:language>'
4542 f'<cp:lastModifiedBy>{escape(creator)}</cp:lastModifiedBy>'
4543 '<cp:revision>1</cp:revision>'
4544 f'<dcterms:created xsi:type="dcterms:W3CDTF">{now}</dcterms:created>'
4545 f'<dcterms:modified xsi:type="dcterms:W3CDTF">{now}</dcterms:modified>'
4546 f'<cp:category>{escape(field("category"))}</cp:category>'
4547 f'<cp:contentStatus>{escape(field("contentStatus"))}</cp:contentStatus>'
4548 '</cp:coreProperties>',
4549 encoding='utf-8',
4550 )
4551
4552 app_path = extract_dir / 'docProps' / 'app.xml'
4553 if app_path.exists():
4554 app = app_path.read_text(encoding='utf-8')
4555 app = re.sub(r'<Slides>.*?</Slides>', f'<Slides>{slide_count}</Slides>', app)
4556 app = re.sub(
4557 r'<Company>.*?</Company>',
4558 f'<Company>{escape(field("company"))}</Company>',
4559 app,
4560 )
4561 app = re.sub(
4562 r'<Manager>.*?</Manager>',
4563 f'<Manager>{escape(field("manager"))}</Manager>',
4564 app,
4565 )
4566 app = re.sub(
4567 r'<Application>.*?</Application>',
4568 '<Application>Microsoft Office PowerPoint</Application>',
4569 app,
4570 )
4571 app = re.sub(
4572 r'<PresentationFormat>.*?</PresentationFormat>',
4573 f'<PresentationFormat>{escape(pres_format)}</PresentationFormat>',
4574 app,
4575 )
4576 app_path.write_text(app, encoding='utf-8')
4577
4578
4579 def _create_preserved_base_pptx(
4580 contract: NativeStructureContract,
4581 specs: list[TemplateSlideSpec],
4582 output_path: Path,
4583 slide_size_emu: tuple[int, int],
4584 ) -> None:
4585 """Create empty slides bound to the source package's original layouts."""
4586 presentation = Presentation(str(contract.source_template))
4587 actual_size = (int(presentation.slide_width), int(presentation.slide_height))
4588 if actual_size != contract.slide_size_emu:
4589 raise TemplateStructureError(
4590 f"{contract.source_template.name} slide size does not match "
4591 f"{contract.contract_path.name}"
4592 )
4593 if actual_size != slide_size_emu:
4594 raise TemplateStructureError(
4595 "Generated SVG canvas does not match the preserved source template size"
4596 )
4597
4598 layouts_by_part = {
4599 str(layout.part.partname).lstrip("/"): layout
4600 for master in presentation.slide_masters
4601 for layout in master.slide_layouts
4602 }
4603 slide_ids = presentation.slides._sldIdLst
4604 for slide_id in list(slide_ids):
4605 presentation.part.drop_rel(slide_id.rId)
4606 slide_ids.remove(slide_id)
4607
4608 for spec in specs:
4609 layout_contract = contract.layout(spec.layout_key)
4610 layout = layouts_by_part.get(layout_contract.package_part)
4611 if layout is None:
4612 raise TemplateStructureError(
4613 f"Preserved source package did not load layout part "
4614 f"{layout_contract.package_part!r}"
4615 )
4616 presentation.slides.add_slide(layout)
4617 presentation.save(str(output_path))
4618
4619
4620 def _clear_preserved_slide_collections(extract_dir: Path) -> None:
4621 """Remove source slide-order metadata that cannot apply to generated pages."""
4622 presentation_path = extract_dir / "ppt" / "presentation.xml"
4623 tree = ET.parse(presentation_path)
4624 root = tree.getroot()
4625 custom_shows = root.find(f"{{{PML_NS}}}custShowLst")
4626 if custom_shows is not None:
4627 root.remove(custom_shows)
4628 for extension_list in root.findall(f".//{{{PML_NS}}}extLst"):
4629 for extension in list(extension_list):
4630 if any(
4631 child.tag.rsplit("}", 1)[-1] == "sectionLst"
4632 for child in extension.iter()
4633 if isinstance(child.tag, str)
4634 ):
4635 extension_list.remove(extension)
4636 _write_xml_tree(presentation_path, tree)
4637
4638
4639 def create_pptx_with_native_svg(
4640 svg_files: list[Path],
4641 output_path: Path,
4642 canvas_format: str | None = None,
4643 verbose: bool = True,
4644 transition: str | None = 'fade',
4645 transition_duration: float = 0.5,
4646 auto_advance: float | None = None,
4647 use_compat_mode: bool = True,
4648 notes: dict[str, str] | None = None,
4649 enable_notes: bool = True,
4650 use_native_shapes: bool = True,
4651 animation: str | None = None,
4652 animation_duration: float = 0.4,
4653 animation_stagger: float = 0.5,
4654 animation_trigger: str = 'after-previous',
4655 animation_config: dict[str, Any] | None = None,
4656 animation_cli_overrides: dict[str, bool] | None = None,
4657 narration_audio: dict[str, Path] | None = None,
4658 use_narration_timings: bool = False,
4659 narration_padding: float = 0.5,
4660 cache_dir: Path | None = None,
4661 workers: int | None = None,
4662 merge_paragraphs: bool | None = None,
4663 image_optimize: bool = True,
4664 image_max_dimension: int | None = 2560,
4665 image_sizing: str = 'cap',
4666 image_scale: float = 2.0,
4667 image_quality: int = 85,
4668 native_objects: bool = False,
4669 conversion_trace_path: Path | None = None,
4670 doc_metadata: dict[str, Any] | None = None,
4671 structure_name: str | None = None,
4672 pptx_structure: str = "structured",
4673 use_layout_placeholder_frames: bool = False,
4674 native_structure_contract: NativeStructureContract | None = None,
4675 theme_font_spec: ThemeFontSpec | None = None,
4676 master_text_style_spec: MasterTextStyleSpec | None = None,
4677 theme_color_spec: ThemeColorSpec | None = None,
4678 structured_baseline: bool = False,
4679 baseline_layout_specs: list[TemplateSlideSpec] | None = None,
4680 layout_definition_files: list[Path] | None = None,
4681 expected_viewbox: str | None = None,
4682 animation_resource_root: Path | None = None,
4683 transition_effect_options: dict[str, object] | None = None,
4684 text_flow: str | None = None,
4685 primary_language: str | None = None,
4686 ) -> bool:
4687 """Create a PPTX file with native DrawingML shapes.
4688
4689 Args:
4690 svg_files: List of SVG files.
4691 output_path: Output PPTX path.
4692 layout_definition_files: Optional structured SVG prototypes for Layouts
4693 that no generated page uses. They are converted on internal carrier
4694 slides, registered, and removed before the package is published.
4695 canvas_format: Canvas format key.
4696 expected_viewbox: Optional project/template-lock canvas contract. Every
4697 public page and internal Layout definition must match it.
4698 animation_resource_root: Base directory for relative animation sound
4699 paths. Defaults to the parent of the SVG source directory.
4700 verbose: Whether to output detailed information.
4701 transition: Transition effect name.
4702 transition_effect_options: PowerPoint Effect Options for the selected
4703 native page transition.
4704 transition_duration: Transition duration in seconds.
4705 auto_advance: Auto-advance interval in seconds.
4706 use_compat_mode: Retained for API compatibility; ignored in native mode.
4707 notes: Notes dict, key is SVG stem, value is notes content.
4708 enable_notes: Whether to enable notes embedding.
4709 use_native_shapes: Must remain true; SVG-image PPTX export is unsupported.
4710 animation: Per-element object-animation mode (compatibility alias,
4711 PowerPoint-native ``entrance_*``/``emphasis_*``/``path_*``/
4712 ``exit_*`` effect, ``'mixed'``, ``'random'``, or None to disable).
4713 Native shapes mode only.
4714 animation_duration: Per-element animation duration in seconds.
4715 Instantaneous native presets retain their PowerPoint-authored
4716 duration.
4717 animation_stagger: Delay between elements in ``after-previous``
4718 trigger mode (seconds). Ignored otherwise.
4719 animation_trigger: PowerPoint Start mode — ``'after-previous'`` (default),
4720 ``'on-click'``, or ``'with-previous'``.
4721 animation_config: Optional sidecar overrides loaded from animations.json.
4722 animation_cli_overrides: Flags indicating explicit CLI overrides.
4723 narration_audio: Optional dict mapping SVG stem to narration audio file.
4724 use_narration_timings: Whether to set slide auto-advance from audio duration.
4725 narration_padding: Extra seconds added after each narration before advancing.
4726 merge_paragraphs: Legacy compatibility option. True selects reflow;
4727 False selects split. Do not combine with ``text_flow``.
4728 text_flow: Positional-tspan policy: preserve authored line breaks in
4729 one frame, reflow text, or split visual lines into separate frames.
4730 image_optimize: Whether native export optimizes raster images when needed.
4731 image_max_dimension: Preferred optimized image dimension cap in pixels.
4732 image_sizing: ``cap`` preserves unchanged source bytes and limits
4733 oversized sources; ``display`` sizes from rendered SVG boxes.
4734 image_scale: Target image pixels per SVG display pixel.
4735 image_quality: JPEG quality used when opaque rasters are re-encoded.
4736 native_objects: Replace explicit ``data-pptx-replace-with`` chart/table
4737 fallback groups with native PowerPoint Chart/Table objects. Default off.
4738 conversion_trace_path: Optional JSON path for native conversion diagnostics.
4739 structure_name: Current deck identity used to name a flat Master, Layout,
4740 and theme.
4741 pptx_structure: PPTX structure strategy. ``baseline`` promotes safe
4742 shared native backgrounds and leading chrome to slide masters,
4743 then extracts semantic page-role layout families and exact
4744 family-wide structurally marked leading chrome; marker-free legacy
4745 SVGs retain filename/id fallback;
4746 ``structured`` consumes explicit SVG master/layout/placeholder
4747 metadata; ``preserve`` reuses an imported source PPTX package;
4748 ``flat`` keeps generated content Slide-local and builds one clean
4749 project-owned Master/Blank-Layout shell.
4750 use_layout_placeholder_frames: In structured template-review decks, size
4751 each Slide placeholder carrier to its reusable Layout bounds instead
4752 of the tight SVG content frame. Default off for generated decks.
4753 native_structure_contract: Validated source package contract for
4754 ``preserve`` mode.
4755 theme_font_spec: Locked project major/minor fonts for flat/structured
4756 release-theme inheritance. Direct diagnostic flat callers may omit it.
4757 master_text_style_spec: Required declared title/body anchors for structured
4758 and release flat slide-master text styles. Direct diagnostic flat
4759 callers may omit it; other routes ignore this value.
4760 theme_color_spec: Locked project color scheme for context-aware
4761 flat/structured theme inheritance. Preserve mode ignores this value.
4762 primary_language: Canonical BCP-47 deck content language. ``None``
4763 preserves legacy per-run language detection.
4764 structured_baseline: Obsolete compatibility argument; must remain false.
4765 baseline_layout_specs: Obsolete compatibility argument; must remain None.
4766
4767 Returns:
4768 Whether all slides were successfully created.
4769 """
4770 text_flow = resolve_text_flow(text_flow, merge_paragraphs)
4771 if primary_language is not None:
4772 primary_language = normalize_language_tag(primary_language)
4773 public_svg_files = list(svg_files)
4774 definition_svg_files = list(layout_definition_files or [])
4775 public_slide_names = [path.stem for path in public_svg_files]
4776 morph_pairs = resolve_morph_pairs(
4777 public_slide_names,
4778 animation_config,
4779 )
4780 public_slide_numbers = {
4781 slide_name: slide_number
4782 for slide_number, slide_name in enumerate(public_slide_names, 1)
4783 }
4784 morph_expectations = tuple(
4785 MorphPairExpectation(
4786 source_slide_number=public_slide_numbers[pair.source_slide],
4787 destination_slide_number=public_slide_numbers[
4788 pair.destination_slide
4789 ],
4790 key=pair.key,
4791 )
4792 for pair in morph_pairs
4793 )
4794 morph_pairs_by_destination: dict[str, list[MorphPair]] = {}
4795 morph_group_overrides_by_slide: dict[str, set[str]] = {}
4796 for pair in morph_pairs:
4797 morph_pairs_by_destination.setdefault(
4798 pair.destination_slide,
4799 [],
4800 ).append(pair)
4801 morph_group_overrides_by_slide.setdefault(
4802 pair.source_slide,
4803 set(),
4804 ).add(pair.source_group_id)
4805 morph_group_overrides_by_slide.setdefault(
4806 pair.destination_slide,
4807 set(),
4808 ).add(pair.destination_group_id)
4809 morph_shape_ids: dict[tuple[str, str], int] = {}
4810 if definition_svg_files and pptx_structure != "structured":
4811 raise ValueError(
4812 "layout_definition_files requires pptx_structure='structured'"
4813 )
4814 public_paths = {path.resolve() for path in public_svg_files}
4815 seen_definition_paths: set[Path] = set()
4816 for path in definition_svg_files:
4817 resolved = path.resolve()
4818 if not path.is_file():
4819 raise ValueError(f"Layout definition SVG does not exist: {path}")
4820 if resolved in public_paths:
4821 raise ValueError(
4822 f"Layout definition SVG is already a generated page: {path}"
4823 )
4824 if resolved in seen_definition_paths:
4825 raise ValueError(f"Layout definition SVG is repeated: {path}")
4826 seen_definition_paths.add(resolved)
4827 public_slide_count = len(public_svg_files)
4828 svg_files = public_svg_files + definition_svg_files
4829 total_slide_count = len(svg_files)
4830
4831 if not use_native_shapes:
4832 raise ValueError(
4833 "SVG-image PPTX export is no longer supported; use svg_final/ "
4834 "directly for preview and native DrawingML PPTX for delivery"
4835 )
4836 if not public_svg_files:
4837 print("Error: No SVG files found")
4838 return False
4839
4840 use_compat_mode = False
4841 if pptx_structure not in {"baseline", "structured", "preserve", "flat"}:
4842 raise ValueError(f"Unsupported pptx_structure: {pptx_structure}")
4843 requested_canvas_format = canvas_format
4844 canvas, detected_canvas_format = resolve_svg_canvas(
4845 svg_files,
4846 canvas_format=canvas_format,
4847 expected_viewbox=expected_viewbox,
4848 )
4849 if canvas_format is None:
4850 canvas_format = detected_canvas_format
4851 if pptx_structure == "flat":
4852 flat_errors = flat_structure_metadata_errors(public_svg_files)
4853 if flat_errors:
4854 details = "\n".join(f" - {error}" for error in flat_errors)
4855 raise TemplateStructureError(
4856 "Flat PPTX structure validation failed:\n" + details
4857 )
4858 if use_layout_placeholder_frames and pptx_structure != "structured":
4859 raise ValueError(
4860 "use_layout_placeholder_frames requires pptx_structure='structured'"
4861 )
4862 if structured_baseline:
4863 raise ValueError(
4864 "structured_baseline is obsolete; use pptx_structure='structured'"
4865 )
4866 if baseline_layout_specs is not None:
4867 raise ValueError(
4868 "baseline_layout_specs is obsolete; structured export parses SVG metadata"
4869 )
4870 if pptx_structure == "structured" and master_text_style_spec is None:
4871 raise ValueError(
4872 "Structured export requires declared typography title/body anchors "
4873 "in master_text_style_spec"
4874 )
4875 if use_native_shapes and pptx_structure == "structured":
4876 template_specs = parse_template_slides(svg_files)
4877 public_template_specs = template_specs[:public_slide_count]
4878 elif use_native_shapes and pptx_structure == "preserve":
4879 if native_structure_contract is None:
4880 raise TemplateStructureError(
4881 "Preserve export requires a validated native structure contract"
4882 )
4883 template_specs = parse_preserve_slides(svg_files)
4884 for spec in template_specs:
4885 native_structure_contract.layout(spec.layout_key)
4886 public_template_specs = template_specs
4887 else:
4888 template_specs = None
4889 public_template_specs = None
4890 template_background_expectations: dict[str, str | None] | None = None
4891 template_shape_roster_expectations: (
4892 dict[str, tuple[str, ...]] | None
4893 ) = None
4894 template_layout_parts_by_key: dict[str, str] | None = None
4895 template_master_parts_by_key: dict[str, str] | None = None
4896 if template_specs is not None and not native_objects:
4897 native_placeholders = sorted({
4898 item.placeholder
4899 for spec in template_specs
4900 for item in spec.placeholders
4901 if item.placeholder in {"chart", "table"}
4902 })
4903 if native_placeholders:
4904 kinds = ", ".join(str(kind) for kind in native_placeholders)
4905 context = (
4906 pptx_structure.capitalize()
4907 )
4908 raise TemplateStructureError(
4909 f"{context} {kinds} placeholder(s) require "
4910 "--native-charts-and-tables so each marker becomes one native "
4911 "PowerPoint Chart/Table object"
4912 )
4913
4914 # Check compatibility mode dependencies
4915 renderer_name, renderer_status, renderer_hint = get_png_renderer_info()
4916 if not use_native_shapes and use_compat_mode and PNG_RENDERER is None:
4917 print("Warning: No PNG rendering library installed, cannot use compatibility mode")
4918 print(f" {renderer_hint}")
4919 print(" Will use pure SVG mode (may not display in Office LTSC 2021 and similar versions)")
4920 use_compat_mode = False
4921
4922 width_emu, height_emu = canvas.emu_dimensions
4923 pixel_width, pixel_height = canvas.pixel_dimensions
4924 pixel_width_label, pixel_height_label = canvas.canonical.split()[2:]
4925 if verbose and requested_canvas_format is None:
4926 if canvas_format:
4927 format_name = CANVAS_FORMATS.get(canvas_format, {}).get('name', canvas_format)
4928 print(f" Detected canvas format: {format_name}")
4929 else:
4930 print(
4931 " Using SVG viewBox dimensions: "
4932 f"{canvas.canonical.removeprefix('0 0 ')} px"
4933 )
4934
4935 if verbose:
4936 print(
4937 f" Slide dimensions: {pixel_width_label} x "
4938 f"{pixel_height_label} px"
4939 )
4940 print(f" SVG file count: {public_slide_count}")
4941 if definition_svg_files:
4942 print(
4943 " Unused Layout definitions: "
4944 f"{len(definition_svg_files)} internal prototype(s)"
4945 )
4946 if use_native_shapes:
4947 print(f" Mode: Native DrawingML shapes (directly editable)")
4948 print(
4949 " Native table/chart objects: "
4950 f"{'Enabled' if native_objects else 'Disabled'}"
4951 )
4952 print(f" PPTX structure: {pptx_structure}")
4953 if image_optimize:
4954 if image_sizing == 'display':
4955 image_mode = (
4956 f"display scale {image_scale:g}, "
4957 f"preferred max {image_max_dimension or 'unlimited'} px"
4958 )
4959 else:
4960 image_mode = (
4961 f"preferred cap {image_max_dimension or 'unlimited'} px, "
4962 "unchanged bytes preserved"
4963 )
4964 print(
4965 " Image optimization: Enabled "
4966 f"({image_mode}, JPEG q{image_quality} when re-encoded)"
4967 )
4968 else:
4969 print(" Image optimization: Disabled (original bytes)")
4970 elif use_compat_mode:
4971 print(f" Compatibility mode: Enabled (PNG + SVG dual format)")
4972 print(f" PNG renderer: {renderer_name} {renderer_status}")
4973 else:
4974 print(f" Compatibility mode: Disabled (pure SVG)")
4975 if transition:
4976 canonical_transition, _transition_options = (
4977 normalize_transition_effect_request(
4978 transition,
4979 transition_effect_options,
4980 )
4981 )
4982 trans_name = (
4983 NATIVE_TRANSITIONS.get(canonical_transition, {}).get(
4984 'name',
4985 canonical_transition,
4986 )
4987 if canonical_transition
4988 else transition
4989 )
4990 print(f" Transition effect: {trans_name}")
4991 if enable_notes and notes:
4992 print(f" Speaker notes: {len(notes)} page(s)")
4993 elif enable_notes:
4994 print(f" Speaker notes: Enabled (no notes files found)")
4995 else:
4996 print(f" Speaker notes: Disabled")
4997 print()
4998
4999 animation_cli_overrides = animation_cli_overrides or {}
5000
5001 temp_dir = _create_writable_work_dir(output_path)
5002
5003 try:
5004 base_pptx = temp_dir / 'base.pptx'
5005 if (
5006 use_native_shapes
5007 and pptx_structure == "preserve"
5008 and native_structure_contract is not None
5009 and template_specs is not None
5010 ):
5011 _create_preserved_base_pptx(
5012 native_structure_contract,
5013 template_specs,
5014 base_pptx,
5015 (width_emu, height_emu),
5016 )
5017 else:
5018 # Create the standard base PPTX with python-pptx.
5019 prs = Presentation()
5020 prs.slide_width = width_emu
5021 prs.slide_height = height_emu
5022
5023 blank_layout = prs.slide_layouts[6]
5024 for _ in svg_files:
5025 prs.slides.add_slide(blank_layout)
5026 prs.save(str(base_pptx))
5027
5028 # Extract PPTX
5029 extract_dir = temp_dir / 'pptx_content'
5030 with zipfile.ZipFile(base_pptx, 'r') as zf:
5031 zf.extractall(extract_dir)
5032 if use_native_shapes and pptx_structure == "preserve":
5033 _clear_preserved_slide_collections(extract_dir)
5034 active_theme_font_spec = (
5035 theme_font_spec
5036 if use_native_shapes
5037 and pptx_structure in {"baseline", "flat", "structured"}
5038 else None
5039 )
5040 if active_theme_font_spec is not None:
5041 apply_theme_font_spec(extract_dir, active_theme_font_spec)
5042 active_theme_color_spec = (
5043 theme_color_spec
5044 if use_native_shapes
5045 and pptx_structure in {"baseline", "flat", "structured"}
5046 else None
5047 )
5048 if active_theme_color_spec is not None:
5049 apply_theme_color_spec(extract_dir, active_theme_color_spec)
5050 structure = _read_slide_layout_targets(extract_dir, len(svg_files))
5051
5052 media_dir = extract_dir / 'ppt' / 'media'
5053 media_dir.mkdir(exist_ok=True)
5054
5055 prerender_results: dict[int, bool] | None = None
5056 if not use_native_shapes and use_compat_mode and PNG_RENDERER is not None:
5057 if workers is None:
5058 resolved_workers = min(os.cpu_count() or 2, len(svg_files), 8)
5059 else:
5060 resolved_workers = max(0, workers)
5061 if verbose:
5062 cache_label = str(cache_dir) if cache_dir else 'disabled'
5063 mode = f'parallel x{resolved_workers}' if resolved_workers > 1 else 'sequential'
5064 print(f" Pre-rendering PNGs ({mode}, cache: {cache_label})")
5065 prerender_results = _prerender_legacy_pngs(
5066 svg_files, media_dir, pixel_width, pixel_height,
5067 cache_dir, resolved_workers, verbose,
5068 )
5069 if verbose:
5070 print()
5071
5072 success_count = 0
5073 has_any_image = False
5074 media_cache: dict[tuple[str, str], str] = {}
5075 image_exts_used: set[str] = set()
5076 package_exts_used: set[str] = set()
5077 package_content_overrides: dict[str, str] = {}
5078 notes_slides_created: set[int] = set()
5079 narration_slides_created: set[int] = set()
5080 audio_exts_used: set[str] = set()
5081 package_uses_timings = False
5082 mixed_animation_offset = 0
5083 animation_defaults_value = _as_dict(
5084 _as_dict(animation_config).get('defaults')
5085 ).get('animation', {})
5086 if not isinstance(animation_defaults_value, dict):
5087 raise ValueError(
5088 'animations.json defaults animation must be an object'
5089 )
5090 default_animation_cfg = animation_defaults_value
5091 animation_seed = json.dumps(
5092 {
5093 'animation': animation,
5094 'config': animation_config,
5095 'slides': [path.name for path in svg_files],
5096 },
5097 ensure_ascii=False,
5098 sort_keys=True,
5099 separators=(',', ':'),
5100 )
5101 animation_rng = random.Random(animation_seed)
5102 conversion_trace: list[dict[str, Any]] | None = [] if conversion_trace_path else None
5103 structure_trace: list[dict[str, Any]] | None = (
5104 []
5105 if use_native_shapes and pptx_structure in {"baseline", "structured", "preserve"}
5106 else None
5107 )
5108
5109 for i, svg_path in enumerate(svg_files, 1):
5110 slide_num = i
5111 is_layout_definition = slide_num > public_slide_count
5112 progress_label = (
5113 f"[Layout definition {slide_num - public_slide_count}/"
5114 f"{len(definition_svg_files)}]"
5115 if is_layout_definition
5116 else f"[Slide {slide_num}/{public_slide_count}]"
5117 )
5118 expected_animation_targets: list[dict[str, Any]] = []
5119 expected_animation_duration = animation_duration
5120 expected_animation_trigger = normalize_animation_trigger(animation_trigger)
5121
5122 try:
5123 # ---- Native shapes mode ----
5124 if use_native_shapes:
5125 slide_cfg = (
5126 {}
5127 if is_layout_definition
5128 else _slide_config(animation_config, svg_path.stem)
5129 )
5130 if is_layout_definition:
5131 slide_transition = None
5132 slide_transition_effect_options = {}
5133 slide_transition_duration = transition_duration
5134 slide_auto_advance = None
5135 slide_animation = None
5136 slide_animation_duration = animation_duration
5137 slide_animation_stagger = animation_stagger
5138 slide_animation_trigger = animation_trigger
5139 slide_animation_cfg = {}
5140 else:
5141 (
5142 slide_transition,
5143 slide_transition_effect_options,
5144 slide_transition_duration,
5145 slide_auto_advance,
5146 ) = _slide_transition_settings(
5147 slide_cfg,
5148 transition,
5149 transition_effect_options,
5150 transition_duration,
5151 auto_advance,
5152 animation_cli_overrides,
5153 )
5154 (
5155 slide_animation,
5156 slide_animation_duration,
5157 slide_animation_stagger,
5158 slide_animation_trigger,
5159 slide_animation_cfg,
5160 ) = _slide_animation_settings(
5161 slide_cfg,
5162 default_animation_cfg,
5163 animation,
5164 animation_duration,
5165 animation_stagger,
5166 animation_trigger,
5167 animation_cli_overrides,
5168 )
5169 if morph_pairs_by_destination.get(svg_path.stem):
5170 if (
5171 slide_transition != "morph"
5172 or slide_transition_effect_options.get(
5173 "morph_by",
5174 "object",
5175 )
5176 != "object"
5177 ):
5178 raise ValueError(
5179 f'animations.json slide "{svg_path.stem}" '
5180 'declares deterministic Morph pairs, but '
5181 'the resolved transition is not Morph by object'
5182 )
5183 groups_value = slide_cfg.get('groups', {})
5184 if not isinstance(groups_value, dict):
5185 raise ValueError(
5186 'animations.json slide groups must be an object'
5187 )
5188 animation_hard_disabled = (
5189 animation_cli_overrides.get('animation', False)
5190 and animation is None
5191 )
5192 explicit_group_ids: set[str] = set()
5193 trigger_group_ids: set[str] = set()
5194 if not animation_hard_disabled:
5195 for group_id, group_cfg in groups_value.items():
5196 if not isinstance(group_cfg, dict):
5197 continue
5198 group_path = (
5199 f'slides['
5200 f'{json.dumps(svg_path.stem, ensure_ascii=False)}'
5201 f'].groups['
5202 f'{json.dumps(str(group_id), ensure_ascii=False)}'
5203 f']'
5204 )
5205 effect_entries = animation_group_effect_entries(
5206 group_cfg,
5207 path=group_path,
5208 )
5209 if any(
5210 effect_cfg.get('effect') != 'none'
5211 and (
5212 slide_animation is not None
5213 or 'effect' in effect_cfg
5214 )
5215 for _effect_path, effect_cfg in effect_entries
5216 ):
5217 explicit_group_ids.add(str(group_id))
5218 for _effect_path, effect_cfg in effect_entries:
5219 trigger_shape = effect_cfg.get('trigger_shape')
5220 if (
5221 isinstance(trigger_shape, str)
5222 and trigger_shape.strip()
5223 ):
5224 trigger_group_ids.add(trigger_shape)
5225 explicit_animation_groups = frozenset(
5226 explicit_group_ids | trigger_group_ids
5227 )
5228 converter_group_overrides = (
5229 explicit_animation_groups
5230 | frozenset(
5231 morph_group_overrides_by_slide.get(
5232 svg_path.stem,
5233 set(),
5234 )
5235 )
5236 )
5237 (
5238 slide_xml,
5239 media_files_dict,
5240 rel_entries,
5241 anim_targets,
5242 package_files_dict,
5243 content_type_overrides,
5244 ) = (
5245 convert_svg_to_slide_shapes(
5246 svg_path, slide_num=slide_num, verbose=verbose,
5247 text_flow=text_flow,
5248 image_optimize=image_optimize,
5249 image_max_dimension=image_max_dimension,
5250 image_sizing=image_sizing,
5251 image_scale=image_scale,
5252 image_quality=image_quality,
5253 native_objects=native_objects,
5254 animation_group_overrides=converter_group_overrides,
5255 theme_font_spec=active_theme_font_spec,
5256 theme_color_spec=active_theme_color_spec,
5257 primary_language=primary_language,
5258 promote_background=pptx_structure != "structured",
5259 trace_out=conversion_trace
5260 if conversion_trace is not None
5261 else structure_trace,
5262 )
5263 )
5264 morph_group_ids = morph_group_overrides_by_slide.get(
5265 svg_path.stem,
5266 set(),
5267 )
5268 if morph_group_ids:
5269 target_ids_by_group: dict[str, list[int]] = {}
5270 for shape_id, group_id in anim_targets:
5271 target_ids_by_group.setdefault(
5272 str(group_id),
5273 [],
5274 ).append(int(shape_id))
5275 for group_id in sorted(morph_group_ids):
5276 resolved_shape_ids = target_ids_by_group.get(
5277 group_id,
5278 [],
5279 )
5280 if len(resolved_shape_ids) != 1:
5281 raise ValueError(
5282 f'Morph target "{svg_path.stem}/{group_id}" '
5283 'must resolve to exactly one Slide-local '
5284 'PowerPoint shape'
5285 )
5286 morph_shape_ids[
5287 (svg_path.stem, group_id)
5288 ] = resolved_shape_ids[0]
5289 # Order matters: OOXML schema requires <p:transition>
5290 # to precede <p:timing> inside <p:sld>. Both use the same
5291 # </p:sld> string-replace anchor, so transition must be
5292 # injected first and timing second.
5293 if slide_transition is not None or slide_auto_advance is not None:
5294 transition_fragment = create_transition_xml(
5295 effect=slide_transition,
5296 duration=slide_transition_duration,
5297 advance_after=slide_auto_advance,
5298 effect_options=slide_transition_effect_options,
5299 )
5300 if transition_fragment:
5301 slide_xml = slide_xml.replace(
5302 '</p:sld>',
5303 '\n' + transition_fragment + '\n</p:sld>',
5304 )
5305 if slide_auto_advance is not None:
5306 package_uses_timings = True
5307
5308 expected_animation_duration = slide_animation_duration
5309 expected_animation_trigger = slide_animation_trigger
5310 if (
5311 not animation_hard_disabled
5312 and (slide_animation or explicit_animation_groups)
5313 and anim_targets
5314 ):
5315 seq_targets, mixed_count = _build_sequence_targets(
5316 anim_targets,
5317 svg_path.stem,
5318 slide_cfg,
5319 slide_animation,
5320 slide_animation_cfg,
5321 slide_animation_duration,
5322 slide_animation_stagger,
5323 mixed_animation_offset,
5324 animation_rng,
5325 )
5326 seq_targets = _materialize_animation_sounds(
5327 (
5328 animation_resource_root
5329 if animation_resource_root is not None
5330 else svg_files[0].parent.parent
5331 ),
5332 seq_targets,
5333 media_files_dict,
5334 rel_entries,
5335 audio_exts_used,
5336 )
5337 expected_animation_targets = seq_targets
5338 if mixed_count:
5339 mixed_animation_offset += mixed_count
5340 timing_xml = '\n' + create_sequence_timing_xml(
5341 seq_targets, duration=slide_animation_duration,
5342 trigger=slide_animation_trigger,
5343 )
5344 slide_xml = slide_xml.replace(
5345 '</p:sld>',
5346 timing_xml + '\n</p:sld>',
5347 )
5348
5349 # Write slide XML
5350 slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
5351 with open(slide_xml_path, 'w', encoding='utf-8') as f:
5352 f.write(slide_xml)
5353
5354 # Write media files
5355 media_name_map: dict[str, str] = {}
5356 for media_name, media_data in media_files_dict.items():
5357 ext = media_name.rsplit('.', 1)[-1].lower()
5358 media_hash = hashlib.sha256(media_data).hexdigest()
5359 cache_key = (ext, media_hash)
5360 cached_name = media_cache.get(cache_key)
5361
5362 if cached_name is None:
5363 prefix = (
5364 'audio'
5365 if f'.{ext}' in AUDIO_CONTENT_TYPES
5366 else 'image'
5367 )
5368 cached_name = f'{prefix}_{media_hash[:16]}.{ext}'
5369 media_cache[cache_key] = cached_name
5370 with open(media_dir / cached_name, 'wb') as f:
5371 f.write(media_data)
5372
5373 media_name_map[media_name] = cached_name
5374
5375 for rel in rel_entries:
5376 target = rel.get('target', '')
5377 if not target.startswith('../media/'):
5378 continue
5379 media_name = target.split('../media/', 1)[1]
5380 mapped_name = media_name_map.get(media_name)
5381 if mapped_name:
5382 rel['target'] = f'../media/{mapped_name}'
5383
5384 # Write non-media OOXML package parts produced by native
5385 # object converters, e.g. chart XML, chart rels, and
5386 # embedded workbooks.
5387 for part_name, part_data in package_files_dict.items():
5388 if (
5389 part_name.startswith('ppt/charts/')
5390 and part_name.endswith('.xml')
5391 ):
5392 part_data = rewrite_chart_accent_colors(
5393 part_data,
5394 active_theme_color_spec,
5395 )
5396 package_path = extract_dir / part_name
5397 package_path.parent.mkdir(parents=True, exist_ok=True)
5398 with open(package_path, 'wb') as f:
5399 f.write(part_data)
5400 suffix = package_path.suffix.lstrip('.').lower()
5401 if suffix:
5402 package_exts_used.add(suffix)
5403 package_content_overrides.update(content_type_overrides)
5404
5405 # Build relationships XML
5406 rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
5407 rels_dir.mkdir(exist_ok=True)
5408 rels_path = rels_dir / f'slide{slide_num}.xml.rels'
5409
5410 extra_rels = ''
5411 for rel in rel_entries:
5412 extra_rels += (
5413 f'\n <Relationship Id="{rel["id"]}" '
5414 f'Type="{rel["type"]}" Target="{rel["target"]}"/>'
5415 )
5416
5417 rels_xml = f'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
5418 <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
5419 <Relationship Id="rId1"
5420 Type="{SLIDE_LAYOUT_REL_TYPE}"
5421 Target="{structure.slide_layout_target(slide_num)}"/>{extra_rels}
5422 </Relationships>'''
5423 with open(rels_path, 'w', encoding='utf-8') as f:
5424 f.write(rels_xml)
5425
5426 # Track image formats for Content_Types
5427 for media_name in media_name_map.values():
5428 ext = media_name.rsplit('.', 1)[-1].lower()
5429 dotted_ext = f'.{ext}'
5430 if dotted_ext in AUDIO_CONTENT_TYPES:
5431 audio_exts_used.add(dotted_ext)
5432 else:
5433 _content_type_for_extension(ext)
5434 image_exts_used.add(ext)
5435 has_any_image = True
5436
5437 # ---- Legacy SVG embedding mode ----
5438 else:
5439 slide_cfg = _slide_config(animation_config, svg_path.stem)
5440 (
5441 slide_transition,
5442 slide_transition_effect_options,
5443 slide_transition_duration,
5444 slide_auto_advance,
5445 ) = (
5446 _slide_transition_settings(
5447 slide_cfg,
5448 transition,
5449 transition_effect_options,
5450 transition_duration,
5451 auto_advance,
5452 animation_cli_overrides,
5453 )
5454 )
5455 svg_filename = f'image{i}.svg'
5456 png_filename = f'image{i}.png'
5457 png_rid = 'rId2'
5458 svg_rid = 'rId3' if use_compat_mode else 'rId2'
5459
5460 shutil.copy(svg_path, media_dir / svg_filename)
5461
5462 slide_has_png = False
5463 if use_compat_mode:
5464 if prerender_results is not None:
5465 png_success = prerender_results.get(i, False)
5466 else:
5467 png_path = media_dir / png_filename
5468 png_success = convert_svg_to_png(
5469 svg_path, png_path,
5470 width=pixel_width, height=pixel_height,
5471 )
5472 if png_success:
5473 slide_has_png = True
5474 has_any_image = True
5475 image_exts_used.add('png')
5476 else:
5477 if verbose:
5478 print(
5479 f" [{i}/{len(svg_files)}] {svg_path.name} - "
5480 "PNG generation failed, using pure SVG"
5481 )
5482 svg_rid = 'rId2'
5483
5484 slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
5485 slide_xml = create_slide_xml_with_svg(
5486 slide_num,
5487 png_rid=png_rid, svg_rid=svg_rid,
5488 width_emu=width_emu, height_emu=height_emu,
5489 transition=slide_transition,
5490 transition_effect_options=slide_transition_effect_options,
5491 transition_duration=slide_transition_duration,
5492 auto_advance=slide_auto_advance,
5493 use_compat_mode=(use_compat_mode and slide_has_png),
5494 )
5495 with open(slide_xml_path, 'w', encoding='utf-8') as f:
5496 f.write(slide_xml)
5497
5498 rels_dir = extract_dir / 'ppt' / 'slides' / '_rels'
5499 rels_dir.mkdir(exist_ok=True)
5500 rels_path = rels_dir / f'slide{slide_num}.xml.rels'
5501 rels_xml = create_slide_rels_xml(
5502 png_rid=png_rid, png_filename=png_filename,
5503 svg_rid=svg_rid, svg_filename=svg_filename,
5504 use_compat_mode=(use_compat_mode and slide_has_png),
5505 slide_layout_target=structure.slide_layout_target(slide_num),
5506 )
5507 with open(rels_path, 'w', encoding='utf-8') as f:
5508 f.write(rels_xml)
5509
5510 resolved_advance_after = slide_auto_advance
5511 resolved_advance_on_click = True
5512
5513 # --- Process notes (shared between native and legacy mode) ---
5514 notes_content = ''
5515 if enable_notes and not is_layout_definition:
5516 svg_stem = svg_path.stem
5517 notes_content = notes.get(svg_stem, '') if notes else ''
5518 notes_text = markdown_to_plain_text(notes_content) if notes_content else ''
5519 if notes_text:
5520 _ensure_notes_master(extract_dir, primary_language)
5521
5522 notes_slides_dir = extract_dir / 'ppt' / 'notesSlides'
5523 notes_slides_dir.mkdir(exist_ok=True)
5524
5525 notes_xml_path = notes_slides_dir / f'notesSlide{slide_num}.xml'
5526 notes_xml = create_notes_slide_xml(
5527 slide_num,
5528 notes_text,
5529 primary_language,
5530 )
5531 with open(notes_xml_path, 'w', encoding='utf-8') as f:
5532 f.write(notes_xml)
5533
5534 notes_rels_dir = notes_slides_dir / '_rels'
5535 notes_rels_dir.mkdir(exist_ok=True)
5536 notes_rels_path = notes_rels_dir / f'notesSlide{slide_num}.xml.rels'
5537 notes_rels_xml = create_notes_slide_rels_xml(slide_num)
5538 with open(notes_rels_path, 'w', encoding='utf-8') as f:
5539 f.write(notes_rels_xml)
5540
5541 _append_relationship(
5542 rels_path,
5543 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesSlide',
5544 f'../notesSlides/notesSlide{slide_num}.xml',
5545 )
5546 notes_slides_created.add(slide_num)
5547
5548 # --- Process narration audio (shared between native and legacy mode) ---
5549 svg_stem = svg_path.stem
5550 audio_path = (
5551 narration_audio.get(svg_stem)
5552 if narration_audio and not is_layout_definition
5553 else None
5554 )
5555 if audio_path:
5556 slide_xml_path = extract_dir / 'ppt' / 'slides' / f'slide{slide_num}.xml'
5557 rels_path = extract_dir / 'ppt' / 'slides' / '_rels' / f'slide{slide_num}.xml.rels'
5558
5559 ext = audio_path.suffix.lower()
5560 media_name = f'narration{slide_num}{ext}'
5561 shutil.copy2(audio_path, media_dir / media_name)
5562 audio_exts_used.add(ext)
5563
5564 poster_name = 'narration_poster.png'
5565 poster_path = media_dir / poster_name
5566 if not poster_path.exists():
5567 poster_path.write_bytes(AUDIO_MARKER_PNG_BYTES)
5568 has_any_image = True
5569 image_exts_used.add('png')
5570
5571 media_rid = _append_relationship(
5572 rels_path,
5573 MEDIA_REL_TYPE,
5574 f'../media/{media_name}',
5575 )
5576 audio_rid = _append_relationship(
5577 rels_path,
5578 AUDIO_REL_TYPE,
5579 f'../media/{media_name}',
5580 )
5581 poster_rid = _append_relationship(
5582 rels_path,
5583 IMAGE_REL_TYPE,
5584 f'../media/{poster_name}',
5585 )
5586
5587 slide_xml = slide_xml_path.read_text(encoding='utf-8')
5588 narration_shape_id = next_shape_id(slide_xml)
5589 slide_xml = inject_narration(
5590 slide_xml,
5591 shape_id=narration_shape_id,
5592 shape_name=media_name,
5593 audio_rid=audio_rid,
5594 media_rid=media_rid,
5595 poster_rid=poster_rid,
5596 )
5597
5598 if use_narration_timings:
5599 duration = probe_audio_duration(audio_path)
5600 if duration is None:
5601 raise RuntimeError(
5602 f"Unable to read narration duration with ffprobe: {audio_path}"
5603 )
5604 slide_xml = apply_recorded_timing(
5605 slide_xml,
5606 advance_after=duration + narration_padding,
5607 transition_duration=slide_transition_duration,
5608 transition_effect=slide_transition,
5609 )
5610 resolved_advance_after = duration + narration_padding
5611 resolved_advance_on_click = False
5612 package_uses_timings = True
5613 slide_xml_path.write_text(slide_xml, encoding='utf-8')
5614 narration_slides_created.add(slide_num)
5615
5616 final_slide_xml = slide_xml_path.read_text(encoding='utf-8')
5617 try:
5618 resolved_motion = validate_generated_transition_xml(
5619 final_slide_xml,
5620 effect=slide_transition,
5621 effect_options=slide_transition_effect_options,
5622 duration=slide_transition_duration,
5623 advance_on_click=resolved_advance_on_click,
5624 advance_after=resolved_advance_after,
5625 )
5626 except ValueError as exc:
5627 raise RuntimeError(
5628 f'Slide {slide_num} transition validation failed: {exc}'
5629 ) from exc
5630 try:
5631 resolved_animation = validate_generated_animation_xml(
5632 final_slide_xml,
5633 expected_animation_targets,
5634 duration=expected_animation_duration,
5635 trigger=expected_animation_trigger,
5636 )
5637 except ValueError as exc:
5638 raise RuntimeError(
5639 f'Slide {slide_num} animation validation failed: {exc}'
5640 ) from exc
5641
5642 if conversion_trace is not None:
5643 motion_summary = asdict(resolved_motion)
5644 for trace_entry in reversed(conversion_trace):
5645 if trace_entry.get('slide_num') == slide_num:
5646 trace_entry['motion'] = motion_summary
5647 trace_entry['animation'] = asdict(resolved_animation)
5648 break
5649
5650 if verbose:
5651 if use_native_shapes:
5652 mode_str = " (Native)"
5653 elif use_compat_mode and not use_native_shapes:
5654 mode_str = " (PNG+SVG)" if has_any_image else " (SVG)"
5655 else:
5656 mode_str = " (SVG)"
5657 has_notes = slide_num in notes_slides_created
5658 notes_str = " +notes" if has_notes else ""
5659 narration_str = " +narration" if slide_num in narration_slides_created else ""
5660 print(
5661 f" {progress_label} {svg_path.name}{mode_str}"
5662 f"{notes_str}{narration_str}"
5663 )
5664
5665 success_count += 1
5666
5667 except Exception as e:
5668 if verbose:
5669 print(
5670 f" {progress_label} {svg_path.name} - Error: {e}"
5671 )
5672 if use_native_shapes:
5673 raise
5674
5675 if (
5676 use_native_shapes
5677 and pptx_structure == "baseline"
5678 and success_count == len(svg_files)
5679 ):
5680 _convert_page_number_texts_to_fields(
5681 extract_dir,
5682 len(svg_files),
5683 conversion_trace if conversion_trace is not None else structure_trace,
5684 context="Baseline",
5685 verbose=verbose,
5686 )
5687 _promote_common_slide_backgrounds_to_masters(
5688 extract_dir,
5689 structure,
5690 len(svg_files),
5691 verbose=verbose,
5692 )
5693 _promote_common_chrome_shapes_to_masters(
5694 extract_dir,
5695 structure,
5696 len(svg_files),
5697 conversion_trace if conversion_trace is not None else structure_trace,
5698 verbose=verbose,
5699 )
5700 _extract_baseline_layout_families(
5701 extract_dir,
5702 structure,
5703 svg_files,
5704 verbose=verbose,
5705 )
5706 _promote_common_chrome_shapes_to_layouts(
5707 extract_dir,
5708 len(svg_files),
5709 conversion_trace if conversion_trace is not None else structure_trace,
5710 verbose=verbose,
5711 )
5712 _prune_unused_slide_layouts(
5713 extract_dir,
5714 structure,
5715 len(svg_files),
5716 verbose=verbose,
5717 )
5718
5719 if (
5720 use_native_shapes
5721 and pptx_structure == "flat"
5722 and success_count == len(svg_files)
5723 ):
5724 _prepare_flat_structure(
5725 extract_dir,
5726 structure,
5727 len(svg_files),
5728 master_text_style_spec,
5729 structure_name,
5730 verbose=verbose,
5731 )
5732
5733 if (
5734 use_native_shapes
5735 and pptx_structure == "structured"
5736 and success_count == len(svg_files)
5737 ):
5738 _convert_page_number_texts_to_fields(
5739 extract_dir,
5740 len(svg_files),
5741 conversion_trace if conversion_trace is not None else structure_trace,
5742 context="Structured",
5743 verbose=verbose,
5744 )
5745 if template_specs is None:
5746 raise TemplateStructureError(
5747 "Structured metadata was not parsed before export"
5748 )
5749 (
5750 template_background_expectations,
5751 template_shape_roster_expectations,
5752 template_layout_parts_by_key,
5753 template_master_parts_by_key,
5754 ) = _apply_explicit_layout_structure(
5755 extract_dir,
5756 structure,
5757 template_specs,
5758 conversion_trace if conversion_trace is not None else structure_trace,
5759 active_theme_font_spec,
5760 use_layout_placeholder_frames=use_layout_placeholder_frames,
5761 verbose=verbose,
5762 )
5763 master_count = apply_master_text_style_spec(
5764 extract_dir,
5765 master_text_style_spec,
5766 )
5767 if verbose:
5768 print(
5769 " Structured master text styles: "
5770 f"{master_count} master(s), "
5771 f"title {master_text_style_spec.title_hpt / 100:g}pt, "
5772 "body levels "
5773 f"{master_text_style_spec.body_levels_hpt[0] / 100:g}–"
5774 f"{master_text_style_spec.body_levels_hpt[-1] / 100:g}pt"
5775 )
5776 _prune_unused_slide_layouts(
5777 extract_dir,
5778 structure,
5779 len(svg_files),
5780 verbose=verbose,
5781 )
5782
5783 if (
5784 use_native_shapes
5785 and pptx_structure == "preserve"
5786 and success_count == len(svg_files)
5787 ):
5788 _convert_page_number_texts_to_fields(
5789 extract_dir,
5790 len(svg_files),
5791 conversion_trace if conversion_trace is not None else structure_trace,
5792 context="Preserve",
5793 verbose=verbose,
5794 )
5795 if template_specs is None or native_structure_contract is None:
5796 raise TemplateStructureError(
5797 "Preserved structure metadata was not parsed before export"
5798 )
5799 _apply_preserved_structure(
5800 extract_dir,
5801 template_specs,
5802 native_structure_contract,
5803 conversion_trace if conversion_trace is not None else structure_trace,
5804 verbose=verbose,
5805 )
5806
5807 if (
5808 use_native_shapes
5809 and pptx_structure == "structured"
5810 and definition_svg_files
5811 and success_count == total_slide_count
5812 ):
5813 removed = _remove_trailing_layout_definition_slides(
5814 extract_dir,
5815 public_slide_count,
5816 total_slide_count,
5817 )
5818 pruned_payload_parts = _prune_unreferenced_definition_payload_parts(
5819 extract_dir
5820 )
5821 for slide_num in range(public_slide_count + 1, total_slide_count + 1):
5822 slide_part = f"ppt/slides/slide{slide_num}.xml"
5823 if template_background_expectations is not None:
5824 template_background_expectations.pop(slide_part, None)
5825 if template_shape_roster_expectations is not None:
5826 template_shape_roster_expectations.pop(slide_part, None)
5827 if verbose:
5828 print(
5829 " Layout definition carriers: "
5830 f"removed {removed} internal slide(s), pruned "
5831 f"{pruned_payload_parts} orphan payload part(s)"
5832 )
5833
5834 morph_trace_names = _apply_morph_shape_names(
5835 extract_dir,
5836 morph_pairs,
5837 public_slide_numbers,
5838 morph_shape_ids,
5839 )
5840 if template_shape_roster_expectations is not None:
5841 for slide_number in morph_trace_names:
5842 slide_part = f"ppt/slides/slide{slide_number}.xml"
5843 template_shape_roster_expectations[
5844 slide_part
5845 ] = _top_level_shape_name_roster(
5846 ET.parse(extract_dir / slide_part).getroot()
5847 )
5848 if conversion_trace is not None:
5849 for trace_entry in conversion_trace:
5850 slide_number = int(trace_entry.get("slide_num", 0))
5851 names = morph_trace_names.get(slide_number)
5852 if names:
5853 trace_entry["morph_names"] = dict(sorted(names.items()))
5854
5855 # Update [Content_Types].xml
5856 content_types_path = extract_dir / '[Content_Types].xml'
5857 with open(content_types_path, 'r', encoding='utf-8') as f:
5858 content_types = f.read()
5859
5860 if not use_native_shapes:
5861 content_types = _add_default_content_type(content_types, 'svg', 'image/svg+xml')
5862 for ext in sorted(image_exts_used):
5863 content_types = _add_default_content_type(
5864 content_types,
5865 ext,
5866 _content_type_for_extension(ext),
5867 )
5868 if 'xlsx' in package_exts_used:
5869 content_types = _add_default_content_type(
5870 content_types,
5871 'xlsx',
5872 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
5873 )
5874 for part_name, content_type in sorted(package_content_overrides.items()):
5875 content_types = _add_content_type_override(content_types, part_name, content_type)
5876 with open(content_types_path, 'w', encoding='utf-8') as f:
5877 f.write(content_types)
5878
5879 if audio_exts_used:
5880 for ext in sorted(audio_exts_used):
5881 content_type = AUDIO_CONTENT_TYPES.get(ext)
5882 if content_type:
5883 content_types = _add_default_content_type(
5884 content_types,
5885 ext.removeprefix('.'),
5886 content_type,
5887 )
5888 if 'Extension="png"' not in content_types:
5889 content_types = _add_default_content_type(content_types, 'png', 'image/png')
5890 with open(content_types_path, 'w', encoding='utf-8') as f:
5891 f.write(content_types)
5892
5893 # Add notes master / slides content types
5894 if enable_notes and notes_slides_created:
5895 notes_theme_override = (
5896 ' <Override PartName="/ppt/theme/theme2.xml" '
5897 'ContentType="application/vnd.openxmlformats-officedocument.theme+xml"/>'
5898 )
5899 if notes_theme_override not in content_types:
5900 content_types = content_types.replace(
5901 '</Types>',
5902 notes_theme_override + '\n</Types>',
5903 )
5904 notes_master_override = (
5905 ' <Override PartName="/ppt/notesMasters/notesMaster1.xml" '
5906 'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"/>'
5907 )
5908 if notes_master_override not in content_types:
5909 content_types = content_types.replace(
5910 '</Types>',
5911 notes_master_override + '\n</Types>',
5912 )
5913 for i in sorted(notes_slides_created):
5914 override = (
5915 f' <Override PartName="/ppt/notesSlides/notesSlide{i}.xml" '
5916 f'ContentType="application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"/>'
5917 )
5918 if override not in content_types:
5919 content_types = content_types.replace('</Types>', override + '\n</Types>')
5920 with open(content_types_path, 'w', encoding='utf-8') as f:
5921 f.write(content_types)
5922
5923 if package_uses_timings:
5924 set_directory_use_timings(extract_dir)
5925
5926 rels_problems = verify_internal_relationships(extract_dir)
5927 if rels_problems:
5928 details = '\n'.join(f' - {p}' for p in rels_problems)
5929 raise RuntimeError(
5930 'PPTX package contains dangling internal relationship targets; '
5931 'PowerPoint will report the file as corrupt:\n' + details
5932 )
5933
5934 # Replace the python-pptx base-template metadata (stale "Steve Canny"
5935 # author, 2013 dates, "generated using python-pptx", Slides=0) with
5936 # accurate, tool-neutral document properties.
5937 pres_format = _presentation_format(width_emu, height_emu)
5938 effective_doc_metadata = dict(doc_metadata or {})
5939 if primary_language is not None:
5940 effective_doc_metadata['language'] = primary_language
5941 _stamp_docprops(
5942 extract_dir,
5943 public_slide_count,
5944 pres_format,
5945 effective_doc_metadata,
5946 )
5947
5948 # Repackage PPTX to a temporary file first. The public output path is
5949 # replaced only after every slide and relationship has succeeded.
5950 temp_output_path = temp_dir / 'result.pptx'
5951 with zipfile.ZipFile(temp_output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
5952 for file_path in extract_dir.rglob('*'):
5953 if file_path.is_file():
5954 arcname = file_path.relative_to(extract_dir)
5955 zf.write(file_path, arcname)
5956 if (
5957 use_native_shapes
5958 and pptx_structure == "structured"
5959 and success_count == len(svg_files)
5960 ):
5961 if template_specs is None or public_template_specs is None:
5962 raise TemplateStructureError(
5963 "Explicit Layout metadata was not parsed before validation"
5964 )
5965 try:
5966 validate_pptx_template_package(
5967 temp_output_path,
5968 public_template_specs,
5969 layout_specs=template_specs,
5970 expected_layout_parts=template_layout_parts_by_key,
5971 expected_master_parts=template_master_parts_by_key,
5972 expected_backgrounds=template_background_expectations,
5973 expected_shape_rosters=template_shape_roster_expectations,
5974 )
5975 except ValueError as exc:
5976 raise TemplateStructureError(
5977 f"PPTX structured package validation failed: {exc}"
5978 ) from exc
5979 try:
5980 validate_pptx_transition_package(
5981 temp_output_path,
5982 require_use_timings=package_uses_timings,
5983 )
5984 except ValueError as exc:
5985 raise RuntimeError(
5986 f'PPTX transition package validation failed: {exc}'
5987 ) from exc
5988 try:
5989 validate_pptx_morph_pairs(
5990 temp_output_path,
5991 morph_expectations,
5992 )
5993 except ValueError as exc:
5994 raise RuntimeError(
5995 f'PPTX Morph package validation failed: {exc}'
5996 ) from exc
5997 try:
5998 validate_pptx_animation_package(
5999 temp_output_path,
6000 require_supported_effects=True,
6001 )
6002 except ValueError as exc:
6003 raise RuntimeError(
6004 f'PPTX animation package validation failed: {exc}'
6005 ) from exc
6006 shutil.move(str(temp_output_path), str(output_path))
6007 permission_warnings = _relax_output_permissions(output_path)
6008
6009 if conversion_trace_path and conversion_trace is not None:
6010 conversion_trace_path.parent.mkdir(parents=True, exist_ok=True)
6011 payload = {
6012 'output': str(output_path),
6013 'slide_count': public_slide_count,
6014 'slides': [
6015 entry
6016 for entry in conversion_trace
6017 if int(entry.get('slide_num', 0)) <= public_slide_count
6018 ],
6019 }
6020 conversion_trace_path.write_text(
6021 json.dumps(payload, ensure_ascii=False, indent=2),
6022 encoding='utf-8',
6023 )
6024
6025 if verbose:
6026 print()
6027 print(f"[Done] Saved: {output_path}")
6028 for warning in permission_warnings:
6029 print(f" [warn] {warning}")
6030 if conversion_trace_path and conversion_trace is not None:
6031 print(f" Trace: {conversion_trace_path}")
6032 print(
6033 f" Slides: {public_slide_count}; "
6034 f"Layout definitions: {len(definition_svg_files)}"
6035 )
6036 if use_compat_mode and has_any_image:
6037 print(f" Mode: Office compatibility mode (supports all Office versions)")
6038 if PNG_RENDERER == 'svglib' and renderer_hint:
6039 print(f" [Tip] {renderer_hint}")
6040
6041 return success_count == len(svg_files)
6042
6043 finally:
6044 shutil.rmtree(temp_dir, ignore_errors=True)
6045
6045 lines PYTHON