返回 ppt-master
template_validation.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / pptx_package / template_validation.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Template Package Validation
4
5 Read a generated structured PPTX back and verify its reusable Master/Layout graph.
6
7 Usage:
8 Imported by svg_to_pptx.pptx_package.builder.
9
10 Examples:
11 validate_pptx_template_package(Path("output.pptx"), template_specs)
12
13 Dependencies:
14 None (only uses standard library and local modules)
15 """
16
17 from __future__ import annotations
18
19 import posixpath
20 import sys
21 import zipfile
22 from collections import Counter
23 from dataclasses import dataclass
24 from pathlib import Path
25 from urllib.parse import unquote, urlsplit
26 from xml.etree import ElementTree as ET
27
28 if __name__ == "__main__":
29 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]):
30 print(__doc__)
31 raise SystemExit(0)
32 print(
33 "Use this validator through the structured SVG-to-PPTX exporter.",
34 file=sys.stderr,
35 )
36 raise SystemExit(1)
37
38 from ..drawingml.utils import EMU_PER_PX
39 from .template_structure import (
40 OOXML_UINT32_MAX,
41 TemplatePlaceholderBinding,
42 TemplateSlideSpec,
43 TemplateStructureError,
44 is_proxy_placeholder,
45 template_placeholder_bindings,
46 )
47
48
49 PML_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
50 DML_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
51 REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
52 P14_NS = "http://schemas.microsoft.com/office/powerpoint/2010/main"
53 MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
54 SLIDE_LAYOUT_REL_TYPE = (
55 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
56 )
57 SLIDE_REL_TYPE = (
58 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
59 )
60 SLIDE_MASTER_REL_TYPE = (
61 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
62 )
63 THEME_REL_TYPE = (
64 "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
65 )
66 SLIDE_LAYOUT_CONTENT_TYPE = (
67 "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"
68 )
69 SLIDE_CONTENT_TYPE = (
70 "application/vnd.openxmlformats-officedocument.presentationml.slide+xml"
71 )
72 SLIDE_MASTER_CONTENT_TYPE = (
73 "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"
74 )
75 PRESENTATION_COLLECTION_ID_MIN = 1 << 31
76 PRESENTATION_SLIDE_ID_MIN = 256
77 _TOP_LEVEL_VISIBLE_TAGS = frozenset({
78 f"{{{PML_NS}}}sp",
79 f"{{{PML_NS}}}pic",
80 f"{{{PML_NS}}}graphicFrame",
81 f"{{{PML_NS}}}grpSp",
82 f"{{{PML_NS}}}cxnSp",
83 f"{{{MC_NS}}}AlternateContent",
84 })
85
86
87 @dataclass(frozen=True)
88 class _Relationship:
89 rel_id: str
90 rel_type: str
91 target: str
92 target_mode: str | None
93
94
95 @dataclass(frozen=True)
96 class _Placeholder:
97 shape: ET.Element
98 placeholder_type: str
99 raw_type: str | None
100 idx: int
101 raw_idx: int | None
102
103
104 class _PackageReader:
105 """Cache package XML while accumulating deterministic read-back errors."""
106
107 def __init__(self, package: zipfile.ZipFile, errors: list[str]) -> None:
108 self.package = package
109 self.errors = errors
110 self.names = frozenset(package.namelist())
111 self._xml_cache: dict[str, ET.Element | None] = {}
112 self._rels_cache: dict[str, tuple[_Relationship, ...]] = {}
113
114 def xml(self, part: str) -> ET.Element | None:
115 if part in self._xml_cache:
116 return self._xml_cache[part]
117 if part not in self.names:
118 self.errors.append(f"missing package part {part}")
119 self._xml_cache[part] = None
120 return None
121 try:
122 root = ET.fromstring(self.package.read(part))
123 except (KeyError, ET.ParseError) as exc:
124 self.errors.append(f"{part} is not valid XML: {exc}")
125 root = None
126 self._xml_cache[part] = root
127 return root
128
129 def relationships(self, source_part: str) -> tuple[_Relationship, ...]:
130 if source_part in self._rels_cache:
131 return self._rels_cache[source_part]
132 rels_part = _relationships_part_for(source_part)
133 root = self.xml(rels_part)
134 relationships: list[_Relationship] = []
135 seen_ids: set[str] = set()
136 if root is not None:
137 for elem in root:
138 attrs = {
139 key.rsplit("}", 1)[-1]: value
140 for key, value in elem.attrib.items()
141 }
142 rel_id = attrs.get("Id", "")
143 rel_type = attrs.get("Type", "")
144 target = attrs.get("Target", "")
145 if not rel_id or not rel_type or not target:
146 self.errors.append(
147 f"{rels_part} contains a relationship without Id, Type, or Target"
148 )
149 continue
150 if rel_id in seen_ids:
151 self.errors.append(f"{rels_part} repeats relationship id {rel_id}")
152 continue
153 seen_ids.add(rel_id)
154 relationships.append(_Relationship(
155 rel_id=rel_id,
156 rel_type=rel_type,
157 target=target,
158 target_mode=attrs.get("TargetMode"),
159 ))
160 result = tuple(relationships)
161 self._rels_cache[source_part] = result
162 return result
163
164
165 def _validate_unique_creation_ids(
166 reader: _PackageReader,
167 parts: set[str],
168 ) -> None:
169 """Reject cloned PowerPoint parts that retain the same p14:creationId."""
170 owners: dict[int, str] = {}
171 for part in sorted(parts):
172 root = reader.xml(part)
173 if root is None:
174 continue
175 c_sld = root.find(f"{{{PML_NS}}}cSld")
176 if c_sld is None:
177 continue
178 seen_in_part: set[int] = set()
179 for creation_id in c_sld.findall(
180 f"{{{PML_NS}}}extLst/{{{PML_NS}}}ext/"
181 f"{{{P14_NS}}}creationId"
182 ):
183 raw_value = creation_id.get("val")
184 try:
185 value = int(raw_value or "")
186 except ValueError:
187 reader.errors.append(
188 f"{part} has invalid p14:creationId value {raw_value!r}"
189 )
190 continue
191 if value < 0 or value > OOXML_UINT32_MAX:
192 reader.errors.append(
193 f"{part} p14:creationId {value} is outside OOXML UInt32"
194 )
195 continue
196 if value in seen_in_part:
197 reader.errors.append(
198 f"{part} repeats p14:creationId {value}"
199 )
200 continue
201 seen_in_part.add(value)
202 previous_owner = owners.setdefault(value, part)
203 if previous_owner != part:
204 reader.errors.append(
205 f"p14:creationId {value} is shared by "
206 f"{previous_owner} and {part}"
207 )
208
209
210 def _top_level_shape_names(
211 root: ET.Element,
212 context: str,
213 errors: list[str],
214 ) -> tuple[str, ...]:
215 """Return deterministic names for every visible top-level OOXML shape."""
216 c_sld = root.find(f"{{{PML_NS}}}cSld")
217 shape_tree = (
218 c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None
219 )
220 if shape_tree is None:
221 errors.append(f"{context} has no p:cSld/p:spTree")
222 return ()
223 names: list[str] = []
224 for child in shape_tree:
225 if child.tag not in _TOP_LEVEL_VISIBLE_TAGS:
226 continue
227 c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
228 name = c_nv_pr.get("name") if c_nv_pr is not None else None
229 if not name:
230 errors.append(f"{context} contains a top-level shape without a name")
231 continue
232 names.append(name)
233 return tuple(names)
234
235
236 def _validate_named_shape_roster(
237 root: ET.Element,
238 expected_names: set[str] | tuple[str, ...] | list[str],
239 context: str,
240 errors: list[str],
241 *,
242 exact: bool,
243 ordered: bool = False,
244 ) -> None:
245 """Validate reusable structure and ordinary Slide carriers by stable name."""
246 expected_sequence = tuple(expected_names)
247 expected_set = set(expected_sequence)
248 actual_names = _top_level_shape_names(root, context, errors)
249 counts = Counter(actual_names)
250 duplicates = sorted(name for name, count in counts.items() if count > 1)
251 if duplicates:
252 errors.append(
253 f"{context} repeats structured shape name(s): " + ", ".join(duplicates)
254 )
255 actual_set = set(actual_names)
256 missing = sorted(expected_set - actual_set)
257 if missing:
258 errors.append(
259 f"{context} is missing structured shape(s): " + ", ".join(missing)
260 )
261 if exact:
262 unexpected = sorted(actual_set - expected_set)
263 if unexpected:
264 errors.append(
265 f"{context} contains unexpected shape(s): "
266 + ", ".join(unexpected)
267 )
268 if ordered and not missing:
269 actual_sequence = (
270 actual_names
271 if exact
272 else tuple(name for name in actual_names if name in expected_set)
273 )
274 if actual_sequence != expected_sequence:
275 errors.append(
276 f"{context} structured shape order is {actual_sequence}, "
277 f"expected {expected_sequence}"
278 )
279
280
281 def _top_level_shape_by_name(
282 root: ET.Element,
283 name: str,
284 ) -> ET.Element | None:
285 c_sld = root.find(f"{{{PML_NS}}}cSld")
286 shape_tree = (
287 c_sld.find(f"{{{PML_NS}}}spTree") if c_sld is not None else None
288 )
289 if shape_tree is None:
290 return None
291 for child in shape_tree:
292 if child.tag not in _TOP_LEVEL_VISIBLE_TAGS:
293 continue
294 c_nv_pr = next(child.iter(f"{{{PML_NS}}}cNvPr"), None)
295 if c_nv_pr is not None and c_nv_pr.get("name") == name:
296 return child
297 return None
298
299
300 def _has_explicit_background(root: ET.Element) -> bool:
301 c_sld = root.find(f"{{{PML_NS}}}cSld")
302 return c_sld is not None and c_sld.find(f"{{{PML_NS}}}bg") is not None
303
304
305 def _xml_payload_signature(elem: ET.Element) -> tuple[object, ...]:
306 """Return a namespace-stable exact XML payload signature."""
307 return (
308 elem.tag,
309 tuple(sorted(elem.attrib.items())),
310 (elem.text or "").strip(),
311 tuple(_xml_payload_signature(child) for child in elem),
312 )
313
314
315 def _background_payload_signatures(
316 root: ET.Element,
317 ) -> tuple[tuple[object, ...], ...]:
318 c_sld = root.find(f"{{{PML_NS}}}cSld")
319 backgrounds = (
320 c_sld.findall(f"{{{PML_NS}}}bg")
321 if c_sld is not None
322 else []
323 )
324 return tuple(_xml_payload_signature(background) for background in backgrounds)
325
326
327 def _expected_background_signature(
328 background_xml: str,
329 ) -> tuple[object, ...]:
330 """Parse a standalone p:bg fragment regardless of its serialized prefix."""
331 try:
332 wrapper = ET.fromstring(
333 f'<root xmlns:p="{PML_NS}" xmlns:a="{DML_NS}">'
334 f"{background_xml}</root>"
335 )
336 except ET.ParseError as exc:
337 raise ValueError(f"invalid expected p:bg payload: {exc}") from exc
338 children = list(wrapper)
339 if len(children) != 1 or children[0].tag != f"{{{PML_NS}}}bg":
340 raise ValueError("expected background payload must contain exactly one p:bg")
341 return _xml_payload_signature(children[0])
342
343
344 def _relationships_part_for(source_part: str) -> str:
345 directory = posixpath.dirname(source_part)
346 filename = posixpath.basename(source_part)
347 return posixpath.join(directory, "_rels", f"{filename}.rels")
348
349
350 def _resolve_relationship_target(
351 source_part: str,
352 relationship: _Relationship,
353 errors: list[str],
354 context: str,
355 ) -> str | None:
356 target_mode = (relationship.target_mode or "Internal").lower()
357 if target_mode != "internal":
358 errors.append(f"{context} relationship must be internal")
359 return None
360 raw_target = relationship.target
361 if "\\" in raw_target or any(ord(char) <= 0x20 for char in raw_target):
362 errors.append(f"{context} has invalid target {raw_target!r}")
363 return None
364 try:
365 parsed = urlsplit(raw_target)
366 except ValueError:
367 errors.append(f"{context} has invalid target {raw_target!r}")
368 return None
369 if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment:
370 errors.append(f"{context} has invalid target {raw_target!r}")
371 return None
372 decoded = unquote(parsed.path)
373 if not decoded:
374 errors.append(f"{context} has an empty target")
375 return None
376 if decoded.startswith("/"):
377 candidate = decoded[1:]
378 else:
379 candidate = posixpath.join(posixpath.dirname(source_part), decoded)
380 normalized = posixpath.normpath(candidate)
381 if (
382 normalized in {"", ".", ".."}
383 or normalized.startswith("../")
384 or normalized.startswith("/")
385 ):
386 errors.append(f"{context} target escapes the package: {raw_target!r}")
387 return None
388 return normalized
389
390
391 def _single_relationship_target(
392 reader: _PackageReader,
393 source_part: str,
394 rel_type: str,
395 context: str,
396 ) -> tuple[_Relationship, str] | None:
397 matches = [
398 relationship
399 for relationship in reader.relationships(source_part)
400 if relationship.rel_type == rel_type
401 ]
402 if len(matches) != 1:
403 reader.errors.append(
404 f"{context} must have exactly one {rel_type.rsplit('/', 1)[-1]} "
405 f"relationship, found {len(matches)}"
406 )
407 return None
408 target = _resolve_relationship_target(
409 source_part,
410 matches[0],
411 reader.errors,
412 context,
413 )
414 if target is None:
415 return None
416 return matches[0], target
417
418
419 def _placeholder_for_shape(shape: ET.Element) -> ET.Element | None:
420 paths = {
421 f"{{{PML_NS}}}sp": (
422 f"{{{PML_NS}}}nvSpPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
423 ),
424 f"{{{PML_NS}}}pic": (
425 f"{{{PML_NS}}}nvPicPr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
426 ),
427 f"{{{PML_NS}}}graphicFrame": (
428 f"{{{PML_NS}}}nvGraphicFramePr/{{{PML_NS}}}nvPr/{{{PML_NS}}}ph"
429 ),
430 }
431 path = paths.get(shape.tag)
432 return shape.find(path) if path else None
433
434
435 def _read_placeholders(
436 root: ET.Element,
437 context: str,
438 errors: list[str],
439 ) -> dict[int, _Placeholder]:
440 sp_tree = root.find(f"{{{PML_NS}}}cSld/{{{PML_NS}}}spTree")
441 if sp_tree is None:
442 errors.append(f"{context} has no p:cSld/p:spTree")
443 return {}
444 placeholders: dict[int, _Placeholder] = {}
445 for shape in sp_tree:
446 ph = _placeholder_for_shape(shape)
447 if ph is None:
448 continue
449 raw_idx_value = ph.get("idx")
450 try:
451 raw_idx = int(raw_idx_value) if raw_idx_value is not None else None
452 except ValueError:
453 errors.append(f"{context} contains invalid placeholder idx {raw_idx_value!r}")
454 continue
455 idx = raw_idx if raw_idx is not None else 0
456 if not 0 <= idx <= OOXML_UINT32_MAX:
457 errors.append(
458 f"{context} placeholder idx {idx} is outside the OOXML UInt32 range"
459 )
460 continue
461 if idx in placeholders:
462 errors.append(f"{context} repeats effective placeholder idx {idx}")
463 continue
464 raw_type = ph.get("type")
465 placeholders[idx] = _Placeholder(
466 shape=shape,
467 placeholder_type=raw_type or "obj",
468 raw_type=raw_type,
469 idx=idx,
470 raw_idx=raw_idx,
471 )
472 return placeholders
473
474
475 def _validate_placeholder_roster(
476 root: ET.Element,
477 bindings: tuple[TemplatePlaceholderBinding, ...],
478 context: str,
479 errors: list[str],
480 *,
481 is_layout: bool,
482 ) -> dict[int, _Placeholder]:
483 actual = _read_placeholders(root, context, errors)
484 expected = {binding.effective_idx: binding for binding in bindings}
485 if set(actual) != set(expected):
486 errors.append(
487 f"{context} placeholder idx roster is {sorted(actual)}, "
488 f"expected {sorted(expected)}"
489 )
490 for idx in sorted(set(actual).intersection(expected)):
491 placeholder = actual[idx]
492 binding = expected[idx]
493 if placeholder.placeholder_type != binding.placeholder_type:
494 errors.append(
495 f"{context} placeholder idx {idx} has type "
496 f"{placeholder.placeholder_type!r}, expected "
497 f"{binding.placeholder_type!r}"
498 )
499 if binding.assigned_idx is None:
500 if placeholder.raw_idx is not None:
501 errors.append(
502 f"{context} title placeholder must use the omitted idx=0 form"
503 )
504 elif placeholder.raw_idx != binding.assigned_idx:
505 errors.append(
506 f"{context} placeholder idx {idx} must serialize explicit idx "
507 f"{binding.assigned_idx}"
508 )
509 if is_layout and placeholder.raw_type is None:
510 errors.append(
511 f"{context} Layout placeholder idx {idx} must serialize its type"
512 )
513 if (
514 not is_layout
515 and binding.placeholder_type != "obj"
516 and placeholder.raw_type is None
517 ):
518 errors.append(
519 f"{context} Slide placeholder idx {idx} must serialize type "
520 f"{binding.placeholder_type!r}"
521 )
522 return actual
523
524
525 def _validate_layout_header_footer(
526 root: ET.Element,
527 bindings: tuple[TemplatePlaceholderBinding, ...],
528 context: str,
529 errors: list[str],
530 ) -> None:
531 placeholder_roles = {
532 binding.element.placeholder for binding in bindings
533 }
534 expected = {
535 "hdr": False,
536 "dt": "date" in placeholder_roles,
537 "ftr": "footer" in placeholder_roles,
538 "sldNum": "slide-number" in placeholder_roles,
539 }
540 header_footer = root.find(f"{{{PML_NS}}}hf")
541 if header_footer is None:
542 if any(expected.values()):
543 errors.append(
544 f"{context} has date/footer/slide-number placeholders but no p:hf"
545 )
546 return
547 for attr, expected_value in expected.items():
548 # CT_HeaderFooter boolean attributes default to true when omitted.
549 raw_value = header_footer.get(attr, "1").lower()
550 if raw_value not in {"0", "1", "false", "true"}:
551 errors.append(f"{context} p:hf@{attr} is not a valid boolean")
552 continue
553 actual_value = raw_value in {"1", "true"}
554 if actual_value != expected_value:
555 errors.append(
556 f"{context} p:hf@{attr} is {raw_value!r}, expected "
557 f"{'1' if expected_value else '0'}"
558 )
559
560
561 def _shape_bounds(
562 shape: ET.Element,
563 context: str,
564 errors: list[str],
565 ) -> tuple[int, int, int, int] | None:
566 if shape.tag == f"{{{PML_NS}}}graphicFrame":
567 xfrm = shape.find(f"{{{PML_NS}}}xfrm")
568 else:
569 xfrm = shape.find(f"{{{PML_NS}}}spPr/{{{DML_NS}}}xfrm")
570 if xfrm is None:
571 errors.append(f"{context} has no direct placeholder transform")
572 return None
573 off = xfrm.find(f"{{{DML_NS}}}off")
574 ext = xfrm.find(f"{{{DML_NS}}}ext")
575 if off is None or ext is None:
576 errors.append(f"{context} placeholder transform has no a:off/a:ext")
577 return None
578 try:
579 bounds = (
580 int(off.attrib["x"]),
581 int(off.attrib["y"]),
582 int(ext.attrib["cx"]),
583 int(ext.attrib["cy"]),
584 )
585 except (KeyError, ValueError):
586 errors.append(f"{context} placeholder transform is invalid")
587 return None
588 if bounds[2] <= 0 or bounds[3] <= 0:
589 errors.append(f"{context} placeholder width/height must be positive")
590 return bounds
591
592
593 def _direct_text_body(shape: ET.Element) -> ET.Element | None:
594 return shape.find(f"{{{PML_NS}}}txBody")
595
596
597 def _first_run_size(shape: ET.Element) -> str | None:
598 text_body = _direct_text_body(shape)
599 if text_body is None:
600 return None
601 run_props = text_body.find(f".//{{{DML_NS}}}rPr")
602 return run_props.get("sz") if run_props is not None else None
603
604
605 def _level_one_default_size(shape: ET.Element) -> str | None:
606 text_body = _direct_text_body(shape)
607 if text_body is None:
608 return None
609 default_props = text_body.find(
610 f"{{{DML_NS}}}lstStyle/"
611 f"{{{DML_NS}}}lvl1pPr/"
612 f"{{{DML_NS}}}defRPr"
613 )
614 return default_props.get("sz") if default_props is not None else None
615
616
617 def _content_type_overrides(
618 reader: _PackageReader,
619 ) -> dict[str, list[str]]:
620 root = reader.xml("[Content_Types].xml")
621 overrides: dict[str, list[str]] = {}
622 if root is None:
623 return overrides
624 for elem in root:
625 if elem.tag.rsplit("}", 1)[-1] != "Override":
626 continue
627 part_name = elem.get("PartName", "")
628 content_type = elem.get("ContentType", "")
629 if part_name and content_type:
630 overrides.setdefault(part_name, []).append(content_type)
631 return overrides
632
633
634 def _validate_uint32_id_roster(
635 elements: list[ET.Element],
636 context: str,
637 errors: list[str],
638 *,
639 min_value: int = 0,
640 ) -> set[int]:
641 values: set[int] = set()
642 for element in elements:
643 raw_value = element.get("id")
644 try:
645 value = int(raw_value) if raw_value is not None else -1
646 except ValueError:
647 value = -1
648 if not min_value <= value <= OOXML_UINT32_MAX:
649 errors.append(
650 f"{context} contains id {raw_value!r} outside the OOXML range "
651 f"{min_value}..{OOXML_UINT32_MAX}"
652 )
653 continue
654 if value in values:
655 errors.append(f"{context} repeats numeric id {value}")
656 continue
657 values.add(value)
658 return values
659
660
661 def _validate_registered_part_roster(
662 reader: _PackageReader,
663 source_part: str,
664 rel_type: str,
665 entries: list[ET.Element],
666 expected_targets: set[str],
667 context: str,
668 *,
669 min_id: int = PRESENTATION_COLLECTION_ID_MIN,
670 ) -> set[int]:
671 relationships = [
672 relationship
673 for relationship in reader.relationships(source_part)
674 if relationship.rel_type == rel_type
675 ]
676 targets_by_id: dict[str, str] = {}
677 ids_by_target: dict[str, list[str]] = {}
678 for relationship in relationships:
679 target = _resolve_relationship_target(
680 source_part,
681 relationship,
682 reader.errors,
683 context,
684 )
685 if target is None:
686 continue
687 targets_by_id[relationship.rel_id] = target
688 ids_by_target.setdefault(target, []).append(relationship.rel_id)
689 for target, rel_ids in sorted(ids_by_target.items()):
690 if len(rel_ids) > 1:
691 reader.errors.append(
692 f"{context} targets {target} through multiple relationships: "
693 + ", ".join(rel_ids)
694 )
695
696 actual_targets = set(targets_by_id.values())
697 if actual_targets != expected_targets:
698 missing = sorted(expected_targets - actual_targets)
699 extra = sorted(actual_targets - expected_targets)
700 reader.errors.append(
701 f"{context} registered target roster differs; missing={missing}, "
702 f"extra={extra}"
703 )
704
705 entry_rel_ids: list[str] = []
706 for entry in entries:
707 rel_id = entry.get(f"{{{REL_NS}}}id")
708 if not rel_id:
709 reader.errors.append(f"{context} contains an entry without r:id")
710 continue
711 entry_rel_ids.append(rel_id)
712 if len(entry_rel_ids) != len(set(entry_rel_ids)):
713 reader.errors.append(f"{context} repeats an entry r:id")
714 if set(entry_rel_ids) != {relationship.rel_id for relationship in relationships}:
715 reader.errors.append(
716 f"{context} entry r:id roster does not match its relationships"
717 )
718 return _validate_uint32_id_roster(
719 entries,
720 context,
721 reader.errors,
722 min_value=min_id,
723 )
724
725
726 def _numbered_part_family(
727 names: frozenset[str],
728 directory: str,
729 stem: str,
730 ) -> set[str]:
731 """Return exact numbered XML parts such as slide12.xml in one directory."""
732 prefix = f"{directory}/{stem}"
733 parts: set[str] = set()
734 for name in names:
735 if not name.startswith(prefix) or not name.endswith(".xml"):
736 continue
737 number = name[len(prefix):-4]
738 if (
739 number.isdigit()
740 and int(number) > 0
741 and posixpath.dirname(name) == directory
742 ):
743 parts.add(name)
744 return parts
745
746
747 def _validate_physical_part_roster(
748 reader: _PackageReader,
749 expected_parts: set[str],
750 directory: str,
751 stem: str,
752 context: str,
753 ) -> None:
754 actual_parts = _numbered_part_family(reader.names, directory, stem)
755 if actual_parts != expected_parts:
756 reader.errors.append(
757 f"{context} physical part roster differs; "
758 f"missing={sorted(expected_parts - actual_parts)}, "
759 f"extra={sorted(actual_parts - expected_parts)}"
760 )
761
762
763 def _validate_content_type_part_roster(
764 overrides: dict[str, list[str]],
765 expected_parts: set[str],
766 directory: str,
767 stem: str,
768 content_type: str,
769 errors: list[str],
770 context: str,
771 ) -> None:
772 family = _numbered_part_family(
773 frozenset(name.lstrip("/") for name in overrides),
774 directory,
775 stem,
776 )
777 if family != expected_parts:
778 errors.append(
779 f"[Content_Types].xml {context} Override roster differs; "
780 f"missing={sorted(expected_parts - family)}, "
781 f"extra={sorted(family - expected_parts)}"
782 )
783 for part in sorted(expected_parts):
784 values = overrides.get(f"/{part}", [])
785 if values != [content_type]:
786 errors.append(
787 f"[Content_Types].xml must declare {part} exactly once as "
788 f"{content_type}"
789 )
790
791
792 def _validate_presentation_master_registration(
793 reader: _PackageReader,
794 master_parts: set[str],
795 ) -> None:
796 presentation_part = "ppt/presentation.xml"
797 presentation_root = reader.xml(presentation_part)
798 if presentation_root is None:
799 return
800 master_id_entries = presentation_root.findall(
801 f"{{{PML_NS}}}sldMasterIdLst/{{{PML_NS}}}sldMasterId"
802 )
803 _validate_registered_part_roster(
804 reader,
805 presentation_part,
806 SLIDE_MASTER_REL_TYPE,
807 master_id_entries,
808 master_parts,
809 "Presentation p:sldMasterIdLst",
810 )
811
812
813 def _validate_master_theme_ownership(
814 reader: _PackageReader,
815 master_parts: set[str],
816 ) -> None:
817 """Require a separate Theme part for every structured Slide Master."""
818 owners: dict[str, str] = {}
819 for master_part in sorted(master_parts):
820 resolved = _single_relationship_target(
821 reader,
822 master_part,
823 THEME_REL_TYPE,
824 f"Master {master_part}",
825 )
826 if resolved is None:
827 continue
828 _relationship, theme_part = resolved
829 if not (
830 theme_part.startswith("ppt/theme/")
831 and theme_part.endswith(".xml")
832 ):
833 reader.errors.append(
834 f"Master {master_part} targets non-Theme part {theme_part}"
835 )
836 continue
837 reader.xml(theme_part)
838 previous_owner = owners.setdefault(theme_part, master_part)
839 if previous_owner != master_part:
840 reader.errors.append(
841 f"Theme {theme_part} is shared by structured Masters "
842 f"{previous_owner} and {master_part}"
843 )
844
845
846 def _validate_presentation_slide_registration(
847 reader: _PackageReader,
848 slide_parts: set[str],
849 ) -> None:
850 presentation_part = "ppt/presentation.xml"
851 presentation_root = reader.xml(presentation_part)
852 if presentation_root is None:
853 return
854 slide_id_entries = presentation_root.findall(
855 f"{{{PML_NS}}}sldIdLst/{{{PML_NS}}}sldId"
856 )
857 _validate_registered_part_roster(
858 reader,
859 presentation_part,
860 SLIDE_REL_TYPE,
861 slide_id_entries,
862 slide_parts,
863 "Presentation p:sldIdLst",
864 min_id=PRESENTATION_SLIDE_ID_MIN,
865 )
866
867
868 def validate_pptx_template_package(
869 pptx_path: str | Path,
870 specs: list[TemplateSlideSpec],
871 *,
872 layout_specs: list[TemplateSlideSpec] | None = None,
873 expected_layout_parts: dict[str, str] | None = None,
874 expected_master_parts: dict[str, str] | None = None,
875 expected_backgrounds: dict[str, str | None] | None = None,
876 expected_shape_rosters: dict[str, tuple[str, ...]] | None = None,
877 ) -> None:
878 """Validate a finished structured PPTX against its explicit SVG contract.
879
880 ``specs`` describes published slides. ``layout_specs`` may additionally
881 include internal prototypes for unused Layouts. Export supplies exact part,
882 background, and shape expectations for deterministic serialization
883 read-back. Callers with only published-slide specs retain the original
884 portable relationship checks.
885 """
886 if not specs:
887 raise ValueError("structured package validation requires at least one slide spec")
888
889 structure_specs = layout_specs or specs
890 specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
891 public_specs_by_layout: dict[str, list[TemplateSlideSpec]] = {}
892 bindings_by_layout: dict[str, tuple[TemplatePlaceholderBinding, ...]] = {}
893 try:
894 for spec in structure_specs:
895 specs_by_layout.setdefault(spec.layout_key, []).append(spec)
896 for spec in specs:
897 public_specs_by_layout.setdefault(spec.layout_key, []).append(spec)
898 for layout_key, layout_specs in specs_by_layout.items():
899 bindings_by_layout[layout_key] = template_placeholder_bindings(
900 layout_specs[0]
901 )
902 except TemplateStructureError as exc:
903 raise ValueError(str(exc)) from exc
904
905 errors: list[str] = []
906 path = Path(pptx_path)
907 try:
908 with zipfile.ZipFile(path) as package:
909 reader = _PackageReader(package, errors)
910 overrides = _content_type_overrides(reader)
911 if expected_backgrounds is not None:
912 for part, background_xml in sorted(expected_backgrounds.items()):
913 root = reader.xml(part)
914 if root is None:
915 continue
916 if background_xml is None:
917 expected_signatures: tuple[tuple[object, ...], ...] = ()
918 else:
919 try:
920 expected_signatures = (
921 _expected_background_signature(background_xml),
922 )
923 except ValueError as exc:
924 errors.append(f"{part} {exc}")
925 continue
926 actual_signatures = _background_payload_signatures(root)
927 if actual_signatures != expected_signatures:
928 errors.append(
929 f"{part} p:bg count/payload differs from the exact "
930 "structured-export expectation"
931 )
932 if expected_shape_rosters is not None:
933 for part, roster in sorted(expected_shape_rosters.items()):
934 root = reader.xml(part)
935 if root is None:
936 continue
937 _validate_named_shape_roster(
938 root,
939 roster,
940 part,
941 errors,
942 exact=True,
943 ordered=True,
944 )
945 layout_parts_by_key = dict(expected_layout_parts or {})
946 keys_by_layout_part = {
947 part: key for key, part in layout_parts_by_key.items()
948 }
949 if len(keys_by_layout_part) != len(layout_parts_by_key):
950 errors.append(
951 "expected Layout part mapping assigns one part to multiple keys"
952 )
953 if expected_layout_parts is not None and (
954 set(expected_layout_parts) != set(specs_by_layout)
955 ):
956 errors.append(
957 "expected Layout key roster differs from the SVG contract; "
958 f"missing={sorted(set(specs_by_layout) - set(expected_layout_parts))}, "
959 f"extra={sorted(set(expected_layout_parts) - set(specs_by_layout))}"
960 )
961 slide_roots: dict[int, ET.Element] = {}
962
963 for spec in specs:
964 slide_part = f"ppt/slides/slide{spec.slide_num}.xml"
965 slide_root = reader.xml(slide_part)
966 if slide_root is not None:
967 slide_roots[spec.slide_num] = slide_root
968 raw_show_inherited = (
969 slide_root.get("showMasterSp", "1").strip().lower()
970 )
971 if raw_show_inherited not in {"0", "1", "false", "true"}:
972 errors.append(
973 f"{slide_part} showMasterSp is not a valid boolean"
974 )
975 elif (
976 (raw_show_inherited in {"1", "true"})
977 != spec.slide_show_inherited_shapes
978 ):
979 errors.append(
980 f"{slide_part} showMasterSp={raw_show_inherited}, "
981 "expected "
982 f"{str(spec.slide_show_inherited_shapes).lower()}"
983 )
984 relationship_target = _single_relationship_target(
985 reader,
986 slide_part,
987 SLIDE_LAYOUT_REL_TYPE,
988 f"Slide {spec.slide_num}",
989 )
990 if relationship_target is None:
991 continue
992 _relationship, layout_part = relationship_target
993 if not (
994 layout_part.startswith("ppt/slideLayouts/")
995 and layout_part.endswith(".xml")
996 ):
997 errors.append(
998 f"Slide {spec.slide_num} targets non-Layout part {layout_part}"
999 )
1000 continue
1001 reader.xml(layout_part)
1002 previous_part = layout_parts_by_key.setdefault(spec.layout_key, layout_part)
1003 if previous_part != layout_part:
1004 errors.append(
1005 f"layout key {spec.layout_key!r} targets both {previous_part} "
1006 f"and {layout_part}"
1007 )
1008 previous_key = keys_by_layout_part.setdefault(
1009 layout_part,
1010 spec.layout_key,
1011 )
1012 if previous_key != spec.layout_key:
1013 errors.append(
1014 f"layout keys {previous_key!r} and {spec.layout_key!r} both "
1015 f"target {layout_part}"
1016 )
1017
1018 used_master_parts: set[str] = set()
1019 layout_parts_by_master: dict[str, set[str]] = {}
1020 master_specs_by_part: dict[str, TemplateSlideSpec] = {}
1021 master_parts_by_key = dict(expected_master_parts or {})
1022 keys_by_master_part = {
1023 part: key for key, part in master_parts_by_key.items()
1024 }
1025 expected_master_keys = {
1026 spec.master_key for spec in structure_specs
1027 }
1028 if len(keys_by_master_part) != len(master_parts_by_key):
1029 errors.append(
1030 "expected Master part mapping assigns one part to multiple keys"
1031 )
1032 if expected_master_parts is not None and (
1033 set(expected_master_parts) != expected_master_keys
1034 ):
1035 errors.append(
1036 "expected Master key roster differs from the SVG contract; "
1037 f"missing={sorted(expected_master_keys - set(expected_master_parts))}, "
1038 f"extra={sorted(set(expected_master_parts) - expected_master_keys)}"
1039 )
1040 for layout_key, layout_specs in specs_by_layout.items():
1041 prototype = layout_specs[0]
1042 layout_part = layout_parts_by_key.get(layout_key)
1043 if layout_part is None:
1044 errors.append(f"layout key {layout_key!r} has no resolved Layout part")
1045 continue
1046 layout_root = reader.xml(layout_part)
1047 if layout_root is None:
1048 continue
1049 if layout_root.tag != f"{{{PML_NS}}}sldLayout":
1050 errors.append(f"{layout_part} is not a p:sldLayout part")
1051 if layout_root.get("type") != "cust":
1052 errors.append(f"{layout_part} must have type='cust'")
1053 if layout_root.get("preserve") != "1":
1054 errors.append(f"{layout_part} must have preserve='1'")
1055 raw_show_master = (
1056 layout_root.get("showMasterSp", "1").strip().lower()
1057 )
1058 if raw_show_master not in {"0", "1", "false", "true"}:
1059 errors.append(
1060 f"{layout_part} showMasterSp is not a valid boolean"
1061 )
1062 elif (
1063 (raw_show_master in {"1", "true"})
1064 != prototype.layout_show_master_shapes
1065 ):
1066 errors.append(
1067 f"{layout_part} showMasterSp={raw_show_master}, "
1068 "expected "
1069 f"{str(prototype.layout_show_master_shapes).lower()}"
1070 )
1071 c_sld = layout_root.find(f"{{{PML_NS}}}cSld")
1072 expected_name = layout_specs[0].layout_name
1073 actual_name = c_sld.get("name") if c_sld is not None else None
1074 if actual_name != expected_name:
1075 errors.append(
1076 f"{layout_part} has picker name {actual_name!r}, expected "
1077 f"{expected_name!r}"
1078 )
1079
1080 override_values = overrides.get(f"/{layout_part}", [])
1081 if override_values != [SLIDE_LAYOUT_CONTENT_TYPE]:
1082 errors.append(
1083 f"[Content_Types].xml must declare {layout_part} exactly once "
1084 f"as {SLIDE_LAYOUT_CONTENT_TYPE}"
1085 )
1086
1087 bindings = bindings_by_layout[layout_key]
1088 layout_placeholders = _validate_placeholder_roster(
1089 layout_root,
1090 bindings,
1091 f"Layout {layout_key!r}",
1092 errors,
1093 is_layout=True,
1094 )
1095 _validate_layout_header_footer(
1096 layout_root,
1097 bindings,
1098 f"Layout {layout_key!r}",
1099 errors,
1100 )
1101 expected_layout_names = tuple(
1102 f"{item.element_id} Layout"
1103 if item.layer == "layout"
1104 else f"{item.element_id} Placeholder"
1105 for item in prototype.elements
1106 if (
1107 (item.layer == "layout" and not item.is_background)
1108 or item.placeholder
1109 )
1110 )
1111 _validate_named_shape_roster(
1112 layout_root,
1113 expected_layout_names,
1114 f"Layout {layout_key!r}",
1115 errors,
1116 exact=True,
1117 ordered=True,
1118 )
1119 expected_layout_background = any(
1120 item.is_background for item in prototype.layout_elements
1121 )
1122 actual_layout_background = _has_explicit_background(layout_root)
1123 if actual_layout_background != expected_layout_background:
1124 errors.append(
1125 f"Layout {layout_key!r} background ownership is "
1126 f"{actual_layout_background}, expected "
1127 f"{expected_layout_background} from the SVG contract"
1128 )
1129 if (
1130 expected_layout_background
1131 and expected_backgrounds is not None
1132 and layout_part not in expected_backgrounds
1133 ):
1134 errors.append(
1135 f"Layout {layout_key!r} has no pre-promotion p:bg payload "
1136 "for exact read-back"
1137 )
1138 slide_placeholders: dict[int, dict[int, _Placeholder]] = {}
1139 for spec in public_specs_by_layout.get(layout_key, []):
1140 slide_root = slide_roots.get(spec.slide_num)
1141 if slide_root is None:
1142 continue
1143 expected_slide_bindings = bindings
1144 slide_placeholders[spec.slide_num] = _validate_placeholder_roster(
1145 slide_root,
1146 expected_slide_bindings,
1147 f"Slide {spec.slide_num}",
1148 errors,
1149 is_layout=False,
1150 )
1151 expected_slide_background = any(
1152 item.layer == "slide" and item.is_background
1153 for item in spec.elements
1154 )
1155 actual_slide_background = _has_explicit_background(slide_root)
1156 if actual_slide_background != expected_slide_background:
1157 errors.append(
1158 f"Slide {spec.slide_num} background ownership is "
1159 f"{actual_slide_background}, expected "
1160 f"{expected_slide_background} from the SVG contract"
1161 )
1162 slide_part = f"ppt/slides/slide{spec.slide_num}.xml"
1163 if (
1164 expected_slide_background
1165 and expected_backgrounds is not None
1166 and slide_part not in expected_backgrounds
1167 ):
1168 errors.append(
1169 f"Slide {spec.slide_num} has no pre-promotion p:bg "
1170 "payload for exact read-back"
1171 )
1172 expected_carrier_names = [
1173 (
1174 f"{item.element_id} Proxy Content"
1175 if is_proxy_placeholder(item)
1176 else f"{item.element_id} Placeholder Carrier"
1177 )
1178 for item in spec.placeholders
1179 ]
1180 expected_carrier_names.extend(
1181 "Placeholder Binding "
1182 f"{binding.placeholder_type} {binding.effective_idx}"
1183 for binding in bindings
1184 if is_proxy_placeholder(binding.element)
1185 )
1186 _validate_named_shape_roster(
1187 slide_root,
1188 expected_carrier_names,
1189 f"Slide {spec.slide_num}",
1190 errors,
1191 exact=False,
1192 ordered=True,
1193 )
1194 bindings_by_element = {
1195 binding.element.element_id: binding
1196 for binding in bindings
1197 }
1198 for item in spec.placeholders:
1199 proxy_binding = is_proxy_placeholder(item)
1200 carrier_name = (
1201 f"{item.element_id} Proxy Content"
1202 if proxy_binding
1203 else f"{item.element_id} Placeholder Carrier"
1204 )
1205 carrier = _top_level_shape_by_name(slide_root, carrier_name)
1206 carrier_placeholder = (
1207 carrier.find(f".//{{{PML_NS}}}ph")
1208 if carrier is not None
1209 else None
1210 )
1211 if proxy_binding:
1212 if carrier_placeholder is not None:
1213 errors.append(
1214 f"Slide {spec.slide_num} proxy content "
1215 f"{carrier_name!r} must remain ordinary"
1216 )
1217 elif carrier is not None and carrier_placeholder is None:
1218 errors.append(
1219 f"Slide {spec.slide_num} carrier "
1220 f"{carrier_name!r} must own its p:ph binding"
1221 )
1222 if not proxy_binding:
1223 continue
1224 placeholder_binding = bindings_by_element[item.element_id]
1225 binding_name = (
1226 "Placeholder Binding "
1227 f"{placeholder_binding.placeholder_type} "
1228 f"{placeholder_binding.effective_idx}"
1229 )
1230 binding_shape = _top_level_shape_by_name(
1231 slide_root,
1232 binding_name,
1233 )
1234 if binding_shape is None:
1235 continue
1236 c_nv_pr = next(
1237 binding_shape.iter(f"{{{PML_NS}}}cNvPr"),
1238 None,
1239 )
1240 if (
1241 c_nv_pr is None
1242 or c_nv_pr.get("hidden", "0").lower()
1243 not in {"1", "true"}
1244 ):
1245 errors.append(
1246 f"Slide {spec.slide_num} binding proxy "
1247 f"{binding_name!r} must be hidden"
1248 )
1249 if binding_shape.find(f".//{{{PML_NS}}}ph") is None:
1250 errors.append(
1251 f"Slide {spec.slide_num} binding proxy "
1252 f"{binding_name!r} has no p:ph"
1253 )
1254 alpha_values = {
1255 alpha.get("val")
1256 for alpha in binding_shape.findall(
1257 f".//{{{DML_NS}}}srgbClr/{{{DML_NS}}}alpha"
1258 )
1259 }
1260 proxy_text = "".join(
1261 node.text or ""
1262 for node in binding_shape.findall(
1263 f".//{{{DML_NS}}}t"
1264 )
1265 )
1266 if "0" not in alpha_values or proxy_text != "\u200b":
1267 errors.append(
1268 f"Slide {spec.slide_num} binding proxy "
1269 f"{binding_name!r} must contain exactly one fully "
1270 "transparent zero-width run"
1271 )
1272
1273 prototype_placeholders = slide_placeholders.get(
1274 prototype.slide_num,
1275 {},
1276 )
1277 for binding in bindings:
1278 idx = binding.effective_idx
1279 layout_placeholder = layout_placeholders.get(idx)
1280 prototype_placeholder = prototype_placeholders.get(idx)
1281 if is_proxy_placeholder(binding.element):
1282 prototype_placeholder = None
1283 if layout_placeholder is None:
1284 continue
1285 context = (
1286 f"Layout {layout_key!r} placeholder "
1287 f"{binding.element.element_id!r}"
1288 )
1289 actual_bounds = _shape_bounds(
1290 layout_placeholder.shape,
1291 context,
1292 errors,
1293 )
1294 if binding.element.placeholder_bounds is not None:
1295 expected_bounds = tuple(
1296 round(value * EMU_PER_PX)
1297 for value in binding.element.placeholder_bounds
1298 )
1299 elif prototype_placeholder is not None:
1300 expected_bounds = _shape_bounds(
1301 prototype_placeholder.shape,
1302 f"Slide {prototype.slide_num} placeholder "
1303 f"{binding.element.element_id!r}",
1304 errors,
1305 )
1306 else:
1307 expected_bounds = None
1308 errors.append(
1309 f"{context} has neither explicit bounds nor a bound "
1310 "prototype Slide placeholder"
1311 )
1312 if (
1313 actual_bounds is not None
1314 and expected_bounds is not None
1315 and actual_bounds != expected_bounds
1316 ):
1317 errors.append(
1318 f"{context} bounds are {actual_bounds}, expected "
1319 f"{expected_bounds}"
1320 )
1321
1322 prompt_size = _first_run_size(layout_placeholder.shape)
1323 default_size = _level_one_default_size(
1324 layout_placeholder.shape
1325 )
1326 if prototype_placeholder is not None:
1327 prototype_size = _first_run_size(
1328 prototype_placeholder.shape
1329 )
1330 if prototype_size is None:
1331 continue
1332 if prompt_size != prototype_size:
1333 errors.append(
1334 f"{context} prompt size is {prompt_size!r}, expected "
1335 f"{prototype_size!r}"
1336 )
1337 if default_size != prototype_size:
1338 errors.append(
1339 f"{context} level-1 default size is "
1340 f"{default_size!r}, expected {prototype_size!r}"
1341 )
1342 elif prompt_size is not None and default_size != prompt_size:
1343 errors.append(
1344 f"{context} level-1 default size is {default_size!r}, "
1345 f"expected its prompt size {prompt_size!r}"
1346 )
1347
1348 master_target = _single_relationship_target(
1349 reader,
1350 layout_part,
1351 SLIDE_MASTER_REL_TYPE,
1352 f"Layout {layout_key!r}",
1353 )
1354 if master_target is None:
1355 continue
1356 _relationship, master_part = master_target
1357 if not (
1358 master_part.startswith("ppt/slideMasters/")
1359 and master_part.endswith(".xml")
1360 ):
1361 errors.append(
1362 f"Layout {layout_key!r} targets non-Master part {master_part}"
1363 )
1364 continue
1365 reader.xml(master_part)
1366 used_master_parts.add(master_part)
1367 previous_master_part = master_parts_by_key.setdefault(
1368 prototype.master_key,
1369 master_part,
1370 )
1371 if previous_master_part != master_part:
1372 errors.append(
1373 f"Master key {prototype.master_key!r} targets both "
1374 f"{previous_master_part} and {master_part}"
1375 )
1376 previous_master_key = keys_by_master_part.setdefault(
1377 master_part,
1378 prototype.master_key,
1379 )
1380 if previous_master_key != prototype.master_key:
1381 errors.append(
1382 f"Master keys {previous_master_key!r} and "
1383 f"{prototype.master_key!r} both target {master_part}"
1384 )
1385 previous_master_spec = master_specs_by_part.setdefault(
1386 master_part,
1387 prototype,
1388 )
1389 if previous_master_spec.master_key != prototype.master_key:
1390 errors.append(
1391 f"{master_part} is shared by conflicting SVG Master contracts"
1392 )
1393 layout_parts_by_master.setdefault(master_part, set()).add(
1394 layout_part
1395 )
1396
1397 layout_id_owners: dict[int, str] = {}
1398 for master_part, layout_parts in sorted(layout_parts_by_master.items()):
1399 master_root = reader.xml(master_part)
1400 if master_root is None:
1401 continue
1402 master_spec = master_specs_by_part.get(master_part)
1403 if master_spec is not None:
1404 master_c_sld = master_root.find(f"{{{PML_NS}}}cSld")
1405 actual_master_name = (
1406 master_c_sld.get("name")
1407 if master_c_sld is not None
1408 else None
1409 )
1410 if actual_master_name != master_spec.master_name:
1411 errors.append(
1412 f"Master {master_part} has picker name "
1413 f"{actual_master_name!r}, expected "
1414 f"{master_spec.master_name!r}"
1415 )
1416 _validate_named_shape_roster(
1417 master_root,
1418 tuple(
1419 f"{item.element_id} Master"
1420 for item in master_spec.master_elements
1421 if not item.is_background
1422 ),
1423 f"Master {master_part}",
1424 errors,
1425 exact=True,
1426 ordered=True,
1427 )
1428 expected_master_background = any(
1429 item.is_background
1430 for item in master_spec.master_elements
1431 )
1432 if (
1433 expected_master_background
1434 and not _has_explicit_background(master_root)
1435 ):
1436 errors.append(
1437 f"Master {master_part} is missing its explicit background"
1438 )
1439 if (
1440 expected_master_background
1441 and expected_backgrounds is not None
1442 and master_part not in expected_backgrounds
1443 ):
1444 errors.append(
1445 f"Master {master_part} has no pre-promotion p:bg payload "
1446 "for exact read-back"
1447 )
1448 layout_id_entries = master_root.findall(
1449 f"{{{PML_NS}}}sldLayoutIdLst/"
1450 f"{{{PML_NS}}}sldLayoutId"
1451 )
1452 layout_ids = _validate_registered_part_roster(
1453 reader,
1454 master_part,
1455 SLIDE_LAYOUT_REL_TYPE,
1456 layout_id_entries,
1457 layout_parts,
1458 f"Master {master_part} p:sldLayoutIdLst",
1459 )
1460 for layout_id in sorted(layout_ids):
1461 previous_owner = layout_id_owners.setdefault(
1462 layout_id,
1463 master_part,
1464 )
1465 if previous_owner != master_part:
1466 errors.append(
1467 f"Slide Layout numeric id {layout_id} appears in both "
1468 f"{previous_owner} and {master_part}"
1469 )
1470
1471 expected_slide_parts = {
1472 f"ppt/slides/slide{spec.slide_num}.xml"
1473 for spec in specs
1474 }
1475 expected_layout_parts = set(layout_parts_by_key.values())
1476 _validate_physical_part_roster(
1477 reader,
1478 expected_slide_parts,
1479 "ppt/slides",
1480 "slide",
1481 "Slide",
1482 )
1483 _validate_physical_part_roster(
1484 reader,
1485 expected_layout_parts,
1486 "ppt/slideLayouts",
1487 "slideLayout",
1488 "Layout",
1489 )
1490 _validate_physical_part_roster(
1491 reader,
1492 used_master_parts,
1493 "ppt/slideMasters",
1494 "slideMaster",
1495 "Master",
1496 )
1497 _validate_content_type_part_roster(
1498 overrides,
1499 expected_slide_parts,
1500 "ppt/slides",
1501 "slide",
1502 SLIDE_CONTENT_TYPE,
1503 errors,
1504 "Slide",
1505 )
1506 _validate_content_type_part_roster(
1507 overrides,
1508 expected_layout_parts,
1509 "ppt/slideLayouts",
1510 "slideLayout",
1511 SLIDE_LAYOUT_CONTENT_TYPE,
1512 errors,
1513 "Layout",
1514 )
1515 _validate_content_type_part_roster(
1516 overrides,
1517 used_master_parts,
1518 "ppt/slideMasters",
1519 "slideMaster",
1520 SLIDE_MASTER_CONTENT_TYPE,
1521 errors,
1522 "Master",
1523 )
1524 _validate_unique_creation_ids(
1525 reader,
1526 expected_slide_parts | expected_layout_parts | used_master_parts,
1527 )
1528 _validate_presentation_slide_registration(reader, expected_slide_parts)
1529 _validate_presentation_master_registration(reader, used_master_parts)
1530 _validate_master_theme_ownership(reader, used_master_parts)
1531 except (OSError, zipfile.BadZipFile) as exc:
1532 raise ValueError(f"cannot read template PPTX package {path}: {exc}") from exc
1533
1534 if errors:
1535 details = "\n".join(f" - {error}" for error in errors)
1536 raise ValueError(f"structured package read-back failed:\n{details}")
1537
1537 lines PYTHON