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