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