返回 ppt-master
template_structure.py
根目录 / skills / ppt-master / scripts / svg_to_pptx / pptx_package / template_structure.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Template Structure Metadata
4
5 Parse and validate explicit SVG metadata consumed by structured PPTX export.
6
7 Usage:
8 Imported by svg_to_pptx.pptx_package.builder and svg_quality_checker.py.
9
10 Examples:
11 parse_template_slides([Path("projects/demo/svg_output/01_cover.svg")])
12
13 Dependencies:
14 None (only uses standard library)
15 """
16
17 from __future__ import annotations
18
19 import hashlib
20 import json
21 import math
22 import re
23 import zipfile
24 from dataclasses import dataclass
25 from pathlib import Path
26 from typing import Any
27 from xml.etree import ElementTree as ET
28
29 from native_payloads import NativePayloadError, hydrate_native_payload_refs
30 from pptx_to_svg.preset_authoring import (
31 authored_preset_encoding,
32 validate_authored_preset_group,
33 )
34
35 from ..drawingml.utils import (
36 is_picture_effect_carrier,
37 parse_project_geometry_length,
38 project_geometry_length_errors,
39 )
40 from ..canvas_contract import CanvasContractError, parse_project_viewbox
41 from ..geometry_properties import (
42 GeometryStyleError,
43 materialize_inline_geometry_properties,
44 )
45 from ..native_objects import NativeMarkerAttributeError, native_replacement_kind
46
47
48 _NON_VISUAL_TAGS = frozenset({"defs", "title", "desc", "metadata", "style"})
49 _STRUCTURE_ATTRS = frozenset({
50 "data-pptx-layer",
51 "data-pptx-layout",
52 "data-pptx-layout-kind",
53 "data-pptx-layout-name",
54 "data-pptx-master",
55 "data-pptx-master-name",
56 "data-pptx-show-inherited-shapes",
57 "data-pptx-show-master-shapes",
58 "data-pptx-placeholder",
59 "data-pptx-binding",
60 "data-pptx-carrier",
61 "data-pptx-idx",
62 "data-pptx-editable",
63 })
64 _FLAT_FORBIDDEN_STRUCTURE_ATTRS = frozenset(
65 _STRUCTURE_ATTRS - {"data-pptx-editable"}
66 )
67 _LAYERS = frozenset({"master", "layout", "slide"})
68 _PLACEHOLDERS = frozenset({
69 "title",
70 "subtitle",
71 "body",
72 "picture",
73 "chart",
74 "table",
75 "object",
76 "media",
77 "date",
78 "footer",
79 "slide-number",
80 })
81 TEMPLATE_PLACEHOLDER_TYPES = {
82 "title": "title",
83 "subtitle": "subTitle",
84 "body": "body",
85 "picture": "pic",
86 "chart": "chart",
87 "table": "tbl",
88 "object": "obj",
89 "media": "media",
90 "date": "dt",
91 "footer": "ftr",
92 "slide-number": "sldNum",
93 }
94 _TEXT_PLACEHOLDERS = frozenset({
95 "title",
96 "subtitle",
97 "body",
98 "date",
99 "footer",
100 "slide-number",
101 })
102 _OBJECT_PLACEHOLDER_TAGS = frozenset({
103 "rect",
104 "circle",
105 "ellipse",
106 "line",
107 "path",
108 "polygon",
109 "polyline",
110 "text",
111 "image",
112 "svg",
113 "use",
114 })
115 _LAYOUT_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
116 _MASTER_KEY_RE = _LAYOUT_KEY_RE
117 # Parse Markdown row syntax before validating each section's key grammar. Keeping
118 # those concerns separate prevents malformed keys from disappearing silently.
119 _LOCK_ROW_RE = re.compile(r"^-\s+([^:]+?)\s*:\s*(.*?)\s*$")
120 _LOCK_PAGE_RE = re.compile(r"^P(\d+)$")
121 PPTX_STRUCTURE_MODES = frozenset({"structured", "preserve", "flat"})
122 TEMPLATE_ADHERENCE_MODES = frozenset({"strict", "adaptive"})
123 TEMPLATE_REUSE_SCOPES = frozenset({"mirror", "layout", "style"})
124 PLACEHOLDER_BINDING_MODES = frozenset({"carrier", "proxy"})
125 _TEMPLATE_SKIN_ATTRS = frozenset({
126 "color",
127 "fill",
128 "fill-opacity",
129 "filter",
130 "font-family",
131 "font-size",
132 "font-style",
133 "font-weight",
134 "letter-spacing",
135 "opacity",
136 "paint-order",
137 "stop-color",
138 "stop-opacity",
139 "stroke",
140 "stroke-dasharray",
141 "stroke-dashoffset",
142 "stroke-linecap",
143 "stroke-linejoin",
144 "stroke-miterlimit",
145 "stroke-opacity",
146 "stroke-width",
147 "style",
148 "text-decoration",
149 "word-spacing",
150 })
151 _CSS_RULE_RE = re.compile(r"(?s)([^{}]+)\{([^{}]*)\}")
152 _CSS_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
153 _CSS_ID_RE = re.compile(r"#([A-Za-z_][A-Za-z0-9_-]*)")
154 _CSS_CLASS_RE = re.compile(r"\.([A-Za-z_][A-Za-z0-9_-]*)")
155 _CSS_ATTR_RE = re.compile(
156 r"\[\s*([A-Za-z_:][A-Za-z0-9_.:-]*)"
157 r"(?:\s*(?:[~|^$*]?=)\s*(['\"]?)([^\]'\"]+)\2)?\s*\]"
158 )
159 _CSS_URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE)
160 _CSS_TAG_RE = re.compile(r"(?:^|[\s>+~])([A-Za-z_][A-Za-z0-9_-]*|\*)")
161 NATIVE_STRUCTURE_SCHEMA = "ppt-master.native-structure.v1"
162 OOXML_UINT32_MAX = (1 << 32) - 1
163
164
165 class TemplateStructureError(RuntimeError):
166 """Reject invalid or ambiguous template-structure metadata."""
167
168
169 @dataclass(frozen=True)
170 class PptxLayoutReference:
171 """One spec_lock page-to-PowerPoint-layout assignment."""
172
173 slide_num: int
174 layout_key: str
175 layout_name: str | None = None
176 master_key: str | None = None
177
178
179 @dataclass(frozen=True)
180 class PptxLayoutDefinition:
181 """One reusable PowerPoint Layout declared by a structured project lock."""
182
183 layout_key: str
184 master_key: str
185 layout_name: str
186 prototype_slide_num: int | None = None
187 prototype_svg_path: Path | None = None
188
189
190 @dataclass(frozen=True)
191 class PptxMasterReference:
192 """One named Master declared by the structured project lock."""
193
194 master_key: str
195 master_name: str
196
197
198 @dataclass(frozen=True)
199 class PptxPrototypeReference:
200 """One spec_lock page-to-template-SVG prototype declaration."""
201
202 slide_num: int
203 template_basename: str
204 svg_path: Path
205 replication_mode: str | None = None
206
207
208 @dataclass(frozen=True)
209 class PptxStructureLock:
210 """Optional project-level PPTX structure export policy."""
211
212 mode: str
213 template_reuse_scope: str | None = None
214 template_adherence: str | None = None
215 masters: tuple[PptxMasterReference, ...] = ()
216 layout_definitions: tuple[PptxLayoutDefinition, ...] = ()
217 layouts: tuple[PptxLayoutReference, ...] = ()
218 prototypes: tuple[PptxPrototypeReference, ...] = ()
219 source_template: Path | None = None
220 native_structure: Path | None = None
221
222
223 @dataclass(frozen=True)
224 class NativePlaceholderSpec:
225 """One placeholder exposed by a preserved source layout."""
226
227 semantic_role: str
228 placeholder_type: str
229 idx: int | None
230 geometry: tuple[float, float, float, float] | None = None
231
232 @property
233 def effective_idx(self) -> int:
234 """Return the OOXML index after applying the omitted-value default."""
235 return self.idx if self.idx is not None else 0
236
237
238 @dataclass(frozen=True)
239 class NativeLayoutSpec:
240 """One named layout retained from the source PPTX package."""
241
242 key: str
243 name: str
244 package_part: str
245 master_key: str
246 placeholders: tuple[NativePlaceholderSpec, ...] = ()
247
248
249 @dataclass(frozen=True)
250 class NativeStructureContract:
251 """Validated portable contract for a preserved source PPTX package."""
252
253 source_template: Path
254 contract_path: Path
255 source_sha256: str
256 slide_size_emu: tuple[int, int]
257 layouts: tuple[NativeLayoutSpec, ...]
258
259 def layout(self, key: str) -> NativeLayoutSpec:
260 for layout in self.layouts:
261 if layout.key == key:
262 return layout
263 raise TemplateStructureError(
264 f"native_structure.json has no layout key {key!r}"
265 )
266
267
268 @dataclass(frozen=True)
269 class TemplateElementSpec:
270 """One direct SVG child carrying explicit PPTX structure metadata."""
271
272 element_id: str
273 order: int
274 tag: str
275 layer: str | None = None
276 placeholder: str | None = None
277 placeholder_bounds: tuple[float, float, float, float] | None = None
278 placeholder_idx: int | None = None
279 placeholder_binding: str | None = None
280 placeholder_carrier_tag: str | None = None
281 is_background: bool = False
282
283 def contract_signature(self) -> tuple[object, ...]:
284 """Return metadata that must agree across slides sharing a structure."""
285 return (
286 self.element_id,
287 self.tag,
288 self.layer,
289 self.placeholder,
290 self.placeholder_bounds,
291 self.placeholder_idx,
292 self.placeholder_binding,
293 self.placeholder_carrier_tag,
294 self.is_background,
295 )
296
297
298 @dataclass(frozen=True)
299 class TemplateSlideSpec:
300 """Explicit structure contract parsed from one SVG slide."""
301
302 slide_num: int
303 svg_path: Path
304 master_key: str
305 master_name: str
306 layout_key: str
307 layout_name: str
308 layout_show_master_shapes: bool
309 slide_show_inherited_shapes: bool
310 elements: tuple[TemplateElementSpec, ...]
311
312 @property
313 def master_elements(self) -> tuple[TemplateElementSpec, ...]:
314 return tuple(item for item in self.elements if item.layer == "master")
315
316 @property
317 def layout_elements(self) -> tuple[TemplateElementSpec, ...]:
318 return tuple(item for item in self.elements if item.layer == "layout")
319
320 @property
321 def placeholders(self) -> tuple[TemplateElementSpec, ...]:
322 return tuple(item for item in self.elements if item.placeholder)
323
324 @property
325 def layout_contract(self) -> tuple[tuple[object, ...], ...]:
326 return tuple(
327 item.contract_signature()
328 for item in self.elements
329 if item.layer == "layout" or item.placeholder
330 )
331
332
333 def is_proxy_placeholder(item: TemplateElementSpec) -> bool:
334 """Return whether a visible composite slot uses an invisible binding proxy."""
335 return item.placeholder_binding == "proxy"
336
337
338 @dataclass(frozen=True)
339 class TemplatePlaceholderBinding:
340 """Resolved PowerPoint identity for one template placeholder."""
341
342 element: TemplateElementSpec
343 placeholder_type: str
344 assigned_idx: int | None
345
346 @property
347 def effective_idx(self) -> int:
348 """Return the OOXML idx value after applying its default of zero."""
349 return self.assigned_idx if self.assigned_idx is not None else 0
350
351
352 def template_placeholder_bindings(
353 spec: TemplateSlideSpec,
354 ) -> tuple[TemplatePlaceholderBinding, ...]:
355 """Assign deterministic, collision-free PowerPoint placeholder identities."""
356 next_idx = 1
357 used_indices: dict[int, str] = {}
358 bindings: list[TemplatePlaceholderBinding] = []
359 for item in spec.placeholders:
360 placeholder_type = TEMPLATE_PLACEHOLDER_TYPES.get(item.placeholder or "")
361 if placeholder_type is None:
362 raise TemplateStructureError(
363 f"{spec.svg_path.name}: unsupported placeholder type "
364 f"{item.placeholder!r}"
365 )
366 if item.placeholder == "title" and item.placeholder_idx is None:
367 assigned_idx = None
368 else:
369 assigned_idx = (
370 item.placeholder_idx
371 if item.placeholder_idx is not None
372 else next_idx
373 )
374 effective_idx = assigned_idx if assigned_idx is not None else 0
375 if effective_idx > OOXML_UINT32_MAX:
376 raise TemplateStructureError(
377 f"{spec.svg_path.name}: layout {spec.layout_key!r} placeholder "
378 f"{item.element_id!r} idx exceeds the OOXML UInt32 maximum "
379 f"{OOXML_UINT32_MAX}"
380 )
381 previous = used_indices.get(effective_idx)
382 if previous is not None:
383 raise TemplateStructureError(
384 f"{spec.svg_path.name}: layout {spec.layout_key!r} gives "
385 f"placeholders {previous!r} and {item.element_id!r} the same "
386 f"effective idx {effective_idx}; omitted idx defaults to 0 in OOXML"
387 )
388 used_indices[effective_idx] = item.element_id
389 if assigned_idx is not None:
390 next_idx = max(next_idx, assigned_idx + 1)
391 bindings.append(TemplatePlaceholderBinding(
392 element=item,
393 placeholder_type=placeholder_type,
394 assigned_idx=assigned_idx,
395 ))
396 return tuple(bindings)
397
398
399 def _local_tag(elem: ET.Element) -> str:
400 return elem.tag.rsplit("}", 1)[-1] if isinstance(elem.tag, str) else ""
401
402
403 def _parse_svg_root(svg_path: Path) -> ET.Element:
404 """Parse one SVG and hydrate compact native metadata in memory."""
405 root = ET.parse(svg_path).getroot()
406 hydrate_native_payload_refs(root, svg_path)
407 return root
408
409
410 def _is_authored_preset_atom(elem: ET.Element) -> bool:
411 """Return whether one group is a valid compact authored-shape atom."""
412 return (
413 authored_preset_encoding(elem) == "compact"
414 and not validate_authored_preset_group(elem)
415 )
416
417
418 def _svg_canvas(root: ET.Element) -> tuple[float, float, float, float]:
419 viewbox = parse_project_viewbox(root.get("viewBox"))
420 return 0.0, 0.0, float(viewbox.width), float(viewbox.height)
421
422
423 def _is_full_canvas_solid_rect(
424 elem: ET.Element,
425 canvas: tuple[float, float, float, float],
426 ) -> bool:
427 """Return whether a direct rect is eligible for scoped p:bg compilation."""
428 if canvas[2] <= 0 or canvas[3] <= 0:
429 return False
430 if _local_tag(elem) != "rect":
431 return False
432 if any(elem.get(attr) for attr in ("transform", "filter", "clip-path")):
433 return False
434 try:
435 geometry = (
436 parse_project_geometry_length(elem.get("x", "0"), "x"),
437 parse_project_geometry_length(elem.get("y", "0"), "y"),
438 parse_project_geometry_length(elem.get("width", "0"), "width"),
439 parse_project_geometry_length(elem.get("height", "0"), "height"),
440 )
441 corner_radius = (
442 parse_project_geometry_length(elem.get("rx", "0"), "rx"),
443 parse_project_geometry_length(elem.get("ry", "0"), "ry"),
444 )
445 except ValueError:
446 return False
447 if not all(math.isfinite(value) for value in (*geometry, *corner_radius)):
448 return False
449 if corner_radius != (0.0, 0.0):
450 return False
451 if any(abs(actual - expected) > 0.5 for actual, expected in zip(geometry, canvas)):
452 return False
453 fill = (elem.get("fill") or "").strip().lower()
454 if not fill or fill == "none" or fill.startswith("url("):
455 return False
456 stroke = (elem.get("stroke") or "none").strip().lower()
457 if stroke != "none":
458 try:
459 if float(elem.get("stroke-opacity", "1")) != 0:
460 return False
461 except ValueError:
462 return False
463 return True
464
465
466 def _portable_project_file(
467 project_path: Path,
468 raw_value: str,
469 field_name: str,
470 suffix: str,
471 ) -> Path:
472 """Resolve a project-relative structure file without allowing escape."""
473 value = raw_value.strip()
474 if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
475 value = value[1:-1].strip()
476 if not value:
477 raise TemplateStructureError(
478 f"spec_lock.md pptx_structure.{field_name} cannot be empty"
479 )
480 candidate = Path(value)
481 if candidate.is_absolute():
482 raise TemplateStructureError(
483 f"spec_lock.md pptx_structure.{field_name} must be project-relative"
484 )
485 root = project_path.resolve()
486 resolved = (root / candidate).resolve()
487 try:
488 resolved.relative_to(root)
489 except ValueError as exc:
490 raise TemplateStructureError(
491 f"spec_lock.md pptx_structure.{field_name} escapes the project directory"
492 ) from exc
493 if resolved.suffix.lower() != suffix:
494 raise TemplateStructureError(
495 f"spec_lock.md pptx_structure.{field_name} must reference a {suffix} file"
496 )
497 if not resolved.is_file():
498 raise TemplateStructureError(
499 f"spec_lock.md pptx_structure.{field_name} does not exist: {candidate}"
500 )
501 return resolved
502
503
504 def _template_replication_mode(template_dir: Path) -> str | None:
505 """Read the optional replication mode from template design frontmatter."""
506 spec_path = template_dir / "design_spec.md"
507 try:
508 lines = spec_path.read_text(encoding="utf-8").splitlines()
509 except OSError:
510 return None
511 if not lines or lines[0].strip() != "---":
512 return None
513 for line in lines[1:]:
514 stripped = line.strip()
515 if stripped == "---":
516 return None
517 match = re.fullmatch(
518 r"replication_mode\s*:\s*[\"']?(standard|fidelity|mirror)[\"']?",
519 stripped,
520 flags=re.IGNORECASE,
521 )
522 if match:
523 return match.group(1).lower()
524 return None
525
526
527 def _template_svg_path(
528 template_dir: Path,
529 raw_basename: str,
530 context: str,
531 ) -> tuple[str, Path]:
532 """Resolve one flat template SVG basename inside the project workspace."""
533 basename = (
534 raw_basename[:-4]
535 if raw_basename.lower().endswith(".svg")
536 else raw_basename
537 )
538 if (
539 not basename
540 or basename in {".", ".."}
541 or "/" in basename
542 or "\\" in basename
543 or any(ord(char) < 0x20 for char in basename)
544 ):
545 raise TemplateStructureError(
546 f"spec_lock.md {context} has invalid template SVG basename "
547 f"{raw_basename!r}"
548 )
549 svg_path = (template_dir / f"{basename}.svg").resolve()
550 if svg_path.parent != template_dir or not svg_path.is_file():
551 raise TemplateStructureError(
552 f"spec_lock.md {context} references missing template SVG "
553 f"templates/{basename}.svg"
554 )
555 return basename, svg_path
556
557
558 def load_pptx_structure_lock(project_path: Path) -> PptxStructureLock | None:
559 """Load optional native-structure sections from spec_lock.md."""
560 lock_path = project_path / "spec_lock.md"
561 if not lock_path.is_file():
562 return None
563 try:
564 lines = lock_path.read_text(encoding="utf-8").splitlines()
565 except OSError as exc:
566 raise TemplateStructureError(f"Cannot read {lock_path}: {exc}") from exc
567
568 sections: dict[str, list[tuple[str, str]]] = {}
569 current_section: str | None = None
570 for line_number, raw_line in enumerate(lines, start=1):
571 line = raw_line.strip()
572 if line.startswith("## "):
573 current_section = line[3:].strip()
574 sections.setdefault(current_section, [])
575 continue
576 if current_section not in {
577 "pptx_structure",
578 "pptx_masters",
579 "pptx_layouts",
580 "page_pptx_layouts",
581 "page_layouts",
582 }:
583 continue
584 match = _LOCK_ROW_RE.fullmatch(line)
585 if match:
586 sections[current_section].append((
587 match.group(1).strip(),
588 match.group(2).strip(),
589 ))
590 elif re.match(r"^-\s+", line):
591 raise TemplateStructureError(
592 f"spec_lock.md {current_section} line {line_number} must use "
593 "'- <key>: <value>' syntax"
594 )
595
596 structure_rows = sections.get("pptx_structure", [])
597 master_rows = sections.get("pptx_masters", [])
598 layout_rows = sections.get("pptx_layouts", [])
599 page_layout_rows = sections.get("page_pptx_layouts", [])
600 prototype_rows = sections.get("page_layouts", [])
601 structure_section_present = "pptx_structure" in sections
602 master_section_present = "pptx_masters" in sections
603 layout_section_present = "pptx_layouts" in sections
604 page_layout_section_present = "page_pptx_layouts" in sections
605 prototype_section_present = "page_layouts" in sections
606 if (
607 not structure_rows
608 and not master_rows
609 and not layout_rows
610 and not page_layout_rows
611 and not prototype_rows
612 and not structure_section_present
613 and not master_section_present
614 and not layout_section_present
615 and not page_layout_section_present
616 and not prototype_section_present
617 ):
618 return None
619 mode_rows = [value.strip().lower() for key, value in structure_rows if key == "mode"]
620 if len(mode_rows) != 1:
621 raise TemplateStructureError(
622 "spec_lock.md pptx_structure requires exactly one '- mode:' row"
623 )
624 mode = mode_rows[0]
625 if mode not in PPTX_STRUCTURE_MODES:
626 allowed = ", ".join(sorted(PPTX_STRUCTURE_MODES))
627 raise TemplateStructureError(
628 f"spec_lock.md pptx_structure.mode must be one of: {allowed}"
629 )
630
631 adherence_rows = [
632 value.strip().lower()
633 for key, value in structure_rows
634 if key == "template_adherence"
635 ]
636 if len(adherence_rows) > 1:
637 raise TemplateStructureError(
638 "spec_lock.md pptx_structure allows at most one "
639 "'- template_adherence:' row"
640 )
641 template_adherence = adherence_rows[0] if adherence_rows else None
642 if template_adherence and template_adherence not in TEMPLATE_ADHERENCE_MODES:
643 allowed = ", ".join(sorted(TEMPLATE_ADHERENCE_MODES))
644 raise TemplateStructureError(
645 "spec_lock.md pptx_structure.template_adherence must be one of: "
646 f"{allowed}"
647 )
648 if mode == "preserve" and template_adherence == "adaptive":
649 raise TemplateStructureError(
650 "spec_lock.md preserve mode requires template_adherence: strict; "
651 "adaptive template use must export through structured mode"
652 )
653 if template_adherence and mode not in {"structured", "preserve"}:
654 raise TemplateStructureError(
655 "spec_lock.md template_adherence is allowed only in structured or "
656 "preserve mode"
657 )
658
659 reuse_scope_rows = [
660 value.strip().lower()
661 for key, value in structure_rows
662 if key == "template_reuse_scope"
663 ]
664 if len(reuse_scope_rows) > 1:
665 raise TemplateStructureError(
666 "spec_lock.md pptx_structure allows at most one "
667 "'- template_reuse_scope:' row"
668 )
669 template_reuse_scope = reuse_scope_rows[0] if reuse_scope_rows else None
670 if (
671 template_reuse_scope
672 and template_reuse_scope not in TEMPLATE_REUSE_SCOPES
673 ):
674 allowed = ", ".join(sorted(TEMPLATE_REUSE_SCOPES))
675 raise TemplateStructureError(
676 "spec_lock.md pptx_structure.template_reuse_scope must be one of: "
677 f"{allowed}"
678 )
679 if mode == "preserve" and template_reuse_scope:
680 raise TemplateStructureError(
681 "spec_lock.md preserve mode does not use template_reuse_scope; "
682 "the source PPTX structure is already authoritative"
683 )
684 if mode == "flat" and template_reuse_scope not in {None, "style"}:
685 raise TemplateStructureError(
686 "spec_lock.md flat mode permits only template_reuse_scope: style"
687 )
688 if mode == "structured" and template_reuse_scope == "style":
689 raise TemplateStructureError(
690 "spec_lock.md template_reuse_scope: style requires mode: flat and "
691 "must omit structured template mappings"
692 )
693 if any(key == "layout_strategy" for key, _value in structure_rows):
694 raise TemplateStructureError(
695 "spec_lock.md pptx_structure.layout_strategy is obsolete; SVG pages "
696 "must declare their final structured Master/Layout contract directly"
697 )
698
699 source_rows = [
700 value for key, value in structure_rows if key == "source_template"
701 ]
702 contract_rows = [
703 value for key, value in structure_rows if key == "native_structure"
704 ]
705 source_template = None
706 native_structure = None
707 if mode == "preserve":
708 if len(source_rows) != 1 or len(contract_rows) != 1:
709 raise TemplateStructureError(
710 "spec_lock.md preserve mode requires exactly one '- source_template:' "
711 "row and one '- native_structure:' row"
712 )
713 source_template = _portable_project_file(
714 project_path,
715 source_rows[0],
716 "source_template",
717 ".pptx",
718 )
719 native_structure = _portable_project_file(
720 project_path,
721 contract_rows[0],
722 "native_structure",
723 ".json",
724 )
725 elif source_rows or contract_rows:
726 raise TemplateStructureError(
727 "spec_lock.md source_template/native_structure rows are allowed only "
728 "when pptx_structure.mode is preserve"
729 )
730
731 masters: list[PptxMasterReference] = []
732 seen_master_keys: set[str] = set()
733 for master_key, raw_name in master_rows:
734 master_name = raw_name.strip()
735 if not _MASTER_KEY_RE.fullmatch(master_key):
736 raise TemplateStructureError(
737 f"spec_lock.md has invalid Master key {master_key!r}; use 1-64 "
738 "characters, start with an ASCII letter or digit, and use only "
739 "ASCII letters, digits, dots, underscores, or hyphens"
740 )
741 if master_key in seen_master_keys:
742 raise TemplateStructureError(
743 f"spec_lock.md pptx_masters repeats Master key {master_key!r}"
744 )
745 if not master_name:
746 raise TemplateStructureError(
747 f"spec_lock.md Master {master_key!r} has an empty name"
748 )
749 seen_master_keys.add(master_key)
750 masters.append(PptxMasterReference(master_key, master_name))
751
752 if mode == "structured":
753 if not master_rows:
754 raise TemplateStructureError(
755 "spec_lock.md structured mode requires a non-empty pptx_masters section"
756 )
757 elif master_section_present:
758 raise TemplateStructureError(
759 "spec_lock.md pptx_masters is allowed only when "
760 "pptx_structure.mode is structured"
761 )
762
763 prototypes: list[PptxPrototypeReference] = []
764 seen_prototype_slides: set[int] = set()
765 template_dir = (project_path / "templates").resolve()
766 template_replication_mode = _template_replication_mode(template_dir)
767 if mode != "structured" and prototype_section_present:
768 raise TemplateStructureError(
769 "spec_lock.md page_layouts section is allowed only when pptx_structure.mode "
770 "is structured"
771 )
772 for page_key, raw_value in prototype_rows:
773 page_match = _LOCK_PAGE_RE.fullmatch(page_key)
774 if not page_match or int(page_match.group(1)) <= 0:
775 raise TemplateStructureError(
776 f"spec_lock.md page_layouts key {page_key!r} must be P<NN>"
777 )
778 slide_num = int(page_match.group(1))
779 if slide_num in seen_prototype_slides:
780 raise TemplateStructureError(
781 f"spec_lock.md page_layouts repeats page P{slide_num:02d}"
782 )
783 seen_prototype_slides.add(slide_num)
784 raw_basename = raw_value.strip()
785 basename, svg_path = _template_svg_path(
786 template_dir,
787 raw_basename,
788 f"page_layouts P{slide_num:02d}",
789 )
790 prototypes.append(PptxPrototypeReference(
791 slide_num=slide_num,
792 template_basename=basename,
793 svg_path=svg_path,
794 replication_mode=template_replication_mode,
795 ))
796
797 if mode == "structured" and template_adherence and not prototypes:
798 raise TemplateStructureError(
799 "spec_lock.md structured template use requires one page_layouts row per page"
800 )
801 if prototypes and not template_adherence:
802 raise TemplateStructureError(
803 "spec_lock.md page_layouts requires template_adherence: strict or adaptive"
804 )
805 if template_reuse_scope in {"mirror", "layout"} and not prototypes:
806 raise TemplateStructureError(
807 "spec_lock.md template_reuse_scope mirror/layout requires one "
808 "page_layouts row per page"
809 )
810 if template_reuse_scope == "style" and prototypes:
811 raise TemplateStructureError(
812 "spec_lock.md template_reuse_scope: style must omit page_layouts"
813 )
814 if template_reuse_scope == "mirror":
815 non_mirror = sorted({
816 reference.template_basename
817 for reference in prototypes
818 if reference.replication_mode != "mirror"
819 })
820 if non_mirror:
821 raise TemplateStructureError(
822 "spec_lock.md template_reuse_scope: mirror requires a mirror "
823 "template workspace; non-mirror prototype(s): "
824 + ", ".join(non_mirror)
825 )
826 if mode == "structured" and template_reuse_scope is None and prototypes:
827 # Backward compatibility: projects created before the explicit reuse
828 # axis inherit their former behavior. Mirror workspaces stay literal;
829 # standard/fidelity workspaces remain structural layout references.
830 template_reuse_scope = (
831 "mirror"
832 if all(
833 reference.replication_mode == "mirror"
834 for reference in prototypes
835 )
836 else "layout"
837 )
838
839 layout_definitions: list[PptxLayoutDefinition] = []
840 references: list[PptxLayoutReference] = []
841 if mode == "structured":
842 if not layout_rows:
843 raise TemplateStructureError(
844 "spec_lock.md structured mode requires a non-empty "
845 "pptx_layouts definition section"
846 )
847 seen_layout_keys: set[str] = set()
848 for layout_key, raw_value in layout_rows:
849 if not _LAYOUT_KEY_RE.fullmatch(layout_key):
850 raise TemplateStructureError(
851 f"spec_lock.md has invalid Layout key {layout_key!r}; use 1-64 "
852 "characters, start with an ASCII letter or digit, and use only "
853 "ASCII letters, digits, dots, underscores, or hyphens"
854 )
855 if layout_key in seen_layout_keys:
856 raise TemplateStructureError(
857 f"spec_lock.md pptx_layouts repeats Layout key {layout_key!r}"
858 )
859 seen_layout_keys.add(layout_key)
860 parts = [part.strip() for part in raw_value.split("|")]
861 if len(parts) != 3 or not all(parts):
862 raise TemplateStructureError(
863 f"spec_lock.md Layout {layout_key!r} must be "
864 "'<master_key> | <PowerPoint layout name> | "
865 "<P<NN> or template:<basename>>'"
866 )
867 master_key, layout_name, raw_source = parts
868 if not _MASTER_KEY_RE.fullmatch(master_key):
869 raise TemplateStructureError(
870 f"spec_lock.md Layout {layout_key!r} has invalid Master key "
871 f"{master_key!r}"
872 )
873 if master_key not in seen_master_keys:
874 raise TemplateStructureError(
875 f"spec_lock.md Layout {layout_key!r} references undeclared "
876 f"Master {master_key!r}"
877 )
878 prototype_slide_num: int | None = None
879 prototype_svg_path: Path | None = None
880 page_match = _LOCK_PAGE_RE.fullmatch(raw_source)
881 if page_match and int(page_match.group(1)) > 0:
882 prototype_slide_num = int(page_match.group(1))
883 elif raw_source.startswith("template:"):
884 raw_basename = raw_source.split(":", 1)[1].strip()
885 _basename, prototype_svg_path = _template_svg_path(
886 template_dir,
887 raw_basename,
888 f"pptx_layouts Layout {layout_key!r}",
889 )
890 else:
891 raise TemplateStructureError(
892 f"spec_lock.md Layout {layout_key!r} prototype source must be "
893 f"P<NN> or template:<basename>; found {raw_source!r}"
894 )
895 layout_definitions.append(PptxLayoutDefinition(
896 layout_key=layout_key,
897 master_key=master_key,
898 layout_name=layout_name,
899 prototype_slide_num=prototype_slide_num,
900 prototype_svg_path=prototype_svg_path,
901 ))
902
903 seen_slides: set[int] = set()
904 for page_key, raw_value in page_layout_rows:
905 page_match = _LOCK_PAGE_RE.fullmatch(page_key)
906 if not page_match or int(page_match.group(1)) <= 0:
907 raise TemplateStructureError(
908 f"spec_lock.md page_pptx_layouts key {page_key!r} must be P<NN>"
909 )
910 slide_num = int(page_match.group(1))
911 if slide_num in seen_slides:
912 raise TemplateStructureError(
913 "spec_lock.md page_pptx_layouts repeats page "
914 f"P{slide_num:02d}"
915 )
916 seen_slides.add(slide_num)
917 layout_key = raw_value.strip()
918 if not _LAYOUT_KEY_RE.fullmatch(layout_key):
919 raise TemplateStructureError(
920 f"spec_lock.md P{slide_num:02d} has invalid Layout key "
921 f"{layout_key!r}"
922 )
923 if layout_key not in seen_layout_keys:
924 raise TemplateStructureError(
925 f"spec_lock.md P{slide_num:02d} references undeclared Layout "
926 f"{layout_key!r}"
927 )
928 references.append(PptxLayoutReference(
929 slide_num=slide_num,
930 layout_key=layout_key,
931 ))
932 if not references:
933 raise TemplateStructureError(
934 "spec_lock.md structured mode requires one page_pptx_layouts "
935 "assignment per generated page"
936 )
937 unused_masters = sorted(
938 seen_master_keys - {
939 definition.master_key for definition in layout_definitions
940 }
941 )
942 if unused_masters:
943 raise TemplateStructureError(
944 "spec_lock.md pptx_masters contains Master key(s) without a "
945 "Layout definition: " + ", ".join(unused_masters)
946 )
947 elif mode == "preserve":
948 if page_layout_section_present:
949 raise TemplateStructureError(
950 "spec_lock.md page_pptx_layouts is reserved for structured mode; "
951 "preserve mode maps source Layouts directly in pptx_layouts"
952 )
953 seen_slides: set[int] = set()
954 for page_key, raw_value in layout_rows:
955 page_match = _LOCK_PAGE_RE.fullmatch(page_key)
956 if not page_match or int(page_match.group(1)) <= 0:
957 raise TemplateStructureError(
958 f"spec_lock.md pptx_layouts key {page_key!r} must be P<NN>"
959 )
960 slide_num = int(page_match.group(1))
961 if slide_num in seen_slides:
962 raise TemplateStructureError(
963 f"spec_lock.md pptx_layouts repeats page P{slide_num:02d}"
964 )
965 seen_slides.add(slide_num)
966 parts = [part.strip() for part in raw_value.split("|")]
967 if len(parts) not in {1, 2} or not parts[0]:
968 raise TemplateStructureError(
969 f"spec_lock.md P{slide_num:02d} preserve mapping must be "
970 "'<layout_key>' or '<layout_key> | <PowerPoint layout name>'"
971 )
972 layout_key = parts[0]
973 if not _LAYOUT_KEY_RE.fullmatch(layout_key):
974 raise TemplateStructureError(
975 f"spec_lock.md P{slide_num:02d} has invalid Layout key "
976 f"{layout_key!r}"
977 )
978 references.append(PptxLayoutReference(
979 slide_num=slide_num,
980 layout_key=layout_key,
981 layout_name=parts[1] if len(parts) == 2 else None,
982 ))
983 if not references:
984 raise TemplateStructureError(
985 "spec_lock.md preserve mode requires one pptx_layouts row per page"
986 )
987 else:
988 if layout_section_present or page_layout_section_present:
989 raise TemplateStructureError(
990 "spec_lock.md pptx_layouts/page_pptx_layouts sections are not "
991 "allowed when pptx_structure.mode is flat"
992 )
993 return PptxStructureLock(
994 mode=mode,
995 template_reuse_scope=template_reuse_scope,
996 template_adherence=template_adherence,
997 masters=tuple(masters),
998 layout_definitions=tuple(layout_definitions),
999 layouts=tuple(sorted(references, key=lambda item: item.slide_num)),
1000 prototypes=tuple(sorted(prototypes, key=lambda item: item.slide_num)),
1001 source_template=source_template,
1002 native_structure=native_structure,
1003 )
1004
1005
1006 def _file_sha256(path: Path) -> str:
1007 digest = hashlib.sha256()
1008 with path.open("rb") as handle:
1009 for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1010 digest.update(chunk)
1011 return digest.hexdigest()
1012
1013
1014 def _native_geometry(raw: Any, context: str) -> tuple[float, float, float, float] | None:
1015 if raw is None:
1016 return None
1017 if not isinstance(raw, dict):
1018 raise TemplateStructureError(f"{context} geometry must be an object or null")
1019 try:
1020 values = tuple(float(raw[key]) for key in ("x", "y", "width", "height"))
1021 except (KeyError, TypeError, ValueError) as exc:
1022 raise TemplateStructureError(f"{context} geometry is invalid") from exc
1023 if not all(math.isfinite(value) for value in values) or values[2] <= 0 or values[3] <= 0:
1024 raise TemplateStructureError(f"{context} geometry must be finite and positive")
1025 return values
1026
1027
1028 def load_native_structure_contract(
1029 structure_lock: PptxStructureLock,
1030 ) -> NativeStructureContract:
1031 """Load and verify the native structure bundle selected by preserve mode."""
1032 if structure_lock.mode != "preserve":
1033 raise TemplateStructureError(
1034 "native structure contracts are available only in preserve mode"
1035 )
1036 source_template = structure_lock.source_template
1037 contract_path = structure_lock.native_structure
1038 if source_template is None or contract_path is None:
1039 raise TemplateStructureError(
1040 "preserve mode is missing source_template or native_structure"
1041 )
1042 try:
1043 raw = json.loads(contract_path.read_text(encoding="utf-8"))
1044 except (OSError, json.JSONDecodeError) as exc:
1045 raise TemplateStructureError(
1046 f"Cannot read native structure contract {contract_path}: {exc}"
1047 ) from exc
1048 if not isinstance(raw, dict) or raw.get("schema") != NATIVE_STRUCTURE_SCHEMA:
1049 raise TemplateStructureError(
1050 f"{contract_path.name} must use schema {NATIVE_STRUCTURE_SCHEMA!r}"
1051 )
1052
1053 source = raw.get("source")
1054 expected_sha = source.get("sha256") if isinstance(source, dict) else None
1055 if not isinstance(expected_sha, str) or not re.fullmatch(r"[0-9a-f]{64}", expected_sha):
1056 raise TemplateStructureError(
1057 f"{contract_path.name} source.sha256 must be a lowercase SHA-256 digest"
1058 )
1059 actual_sha = _file_sha256(source_template)
1060 if actual_sha != expected_sha:
1061 raise TemplateStructureError(
1062 f"{source_template.name} does not match {contract_path.name} source.sha256"
1063 )
1064
1065 slide_size = raw.get("slideSize")
1066 try:
1067 slide_size_emu = (
1068 int(slide_size["width_emu"]),
1069 int(slide_size["height_emu"]),
1070 )
1071 except (KeyError, TypeError, ValueError) as exc:
1072 raise TemplateStructureError(
1073 f"{contract_path.name} slideSize must contain width_emu/height_emu"
1074 ) from exc
1075 if slide_size_emu[0] <= 0 or slide_size_emu[1] <= 0:
1076 raise TemplateStructureError(
1077 f"{contract_path.name} slideSize values must be positive"
1078 )
1079
1080 raw_layouts = raw.get("layouts")
1081 if not isinstance(raw_layouts, list) or not raw_layouts:
1082 raise TemplateStructureError(
1083 f"{contract_path.name} must contain at least one layout"
1084 )
1085 layouts: list[NativeLayoutSpec] = []
1086 seen_keys: set[str] = set()
1087 seen_parts: set[str] = set()
1088 for index, item in enumerate(raw_layouts, start=1):
1089 context = f"{contract_path.name} layouts[{index}]"
1090 if not isinstance(item, dict):
1091 raise TemplateStructureError(f"{context} must be an object")
1092 key = str(item.get("key") or "")
1093 name = str(item.get("name") or "").strip()
1094 package_part = str(item.get("packagePart") or "")
1095 master_key = str(item.get("masterKey") or "")
1096 if not _LAYOUT_KEY_RE.fullmatch(key):
1097 raise TemplateStructureError(f"{context} has invalid key {key!r}")
1098 if key in seen_keys:
1099 raise TemplateStructureError(f"{context} repeats layout key {key!r}")
1100 if not name:
1101 raise TemplateStructureError(f"{context} name cannot be empty")
1102 if (
1103 not package_part.startswith("ppt/slideLayouts/")
1104 or ".." in Path(package_part).parts
1105 or not package_part.endswith(".xml")
1106 ):
1107 raise TemplateStructureError(
1108 f"{context} packagePart must be a ppt/slideLayouts/*.xml part"
1109 )
1110 if package_part in seen_parts:
1111 raise TemplateStructureError(
1112 f"{context} repeats package part {package_part!r}"
1113 )
1114 if not master_key:
1115 raise TemplateStructureError(f"{context} masterKey cannot be empty")
1116
1117 raw_placeholders = item.get("placeholders", [])
1118 if not isinstance(raw_placeholders, list):
1119 raise TemplateStructureError(f"{context} placeholders must be a list")
1120 placeholders: list[NativePlaceholderSpec] = []
1121 for ph_index, placeholder in enumerate(raw_placeholders, start=1):
1122 ph_context = f"{context} placeholders[{ph_index}]"
1123 if not isinstance(placeholder, dict):
1124 raise TemplateStructureError(f"{ph_context} must be an object")
1125 semantic_role = str(placeholder.get("semanticRole") or "other")
1126 placeholder_type = str(placeholder.get("type") or "obj")
1127 raw_idx = placeholder.get("idx")
1128 try:
1129 placeholder_idx = int(raw_idx) if raw_idx is not None else None
1130 except (TypeError, ValueError) as exc:
1131 raise TemplateStructureError(
1132 f"{ph_context} idx must be an integer or null"
1133 ) from exc
1134 if placeholder_idx is not None and placeholder_idx < 0:
1135 raise TemplateStructureError(f"{ph_context} idx cannot be negative")
1136 placeholders.append(NativePlaceholderSpec(
1137 semantic_role=semantic_role,
1138 placeholder_type=placeholder_type,
1139 idx=placeholder_idx,
1140 geometry=_native_geometry(placeholder.get("geometry"), ph_context),
1141 ))
1142 layouts.append(NativeLayoutSpec(
1143 key=key,
1144 name=name,
1145 package_part=package_part,
1146 master_key=master_key,
1147 placeholders=tuple(placeholders),
1148 ))
1149 seen_keys.add(key)
1150 seen_parts.add(package_part)
1151
1152 try:
1153 with zipfile.ZipFile(source_template, "r") as package:
1154 package_parts = set(package.namelist())
1155 except (OSError, zipfile.BadZipFile) as exc:
1156 raise TemplateStructureError(
1157 f"Cannot open preserved source template {source_template}: {exc}"
1158 ) from exc
1159 missing_parts = sorted(seen_parts - package_parts)
1160 if missing_parts:
1161 raise TemplateStructureError(
1162 f"{source_template.name} is missing layout part(s): " + ", ".join(missing_parts)
1163 )
1164
1165 return NativeStructureContract(
1166 source_template=source_template,
1167 contract_path=contract_path,
1168 source_sha256=expected_sha,
1169 slide_size_emu=slide_size_emu,
1170 layouts=tuple(layouts),
1171 )
1172
1173
1174 def _parse_placeholder_bounds(
1175 raw: str | None,
1176 *,
1177 svg_path: Path,
1178 element_id: str,
1179 ) -> tuple[float, float, float, float] | None:
1180 if raw is None:
1181 return None
1182 parts = [part for part in re.split(r"[\s,]+", raw.strip()) if part]
1183 if len(parts) != 4:
1184 raise TemplateStructureError(
1185 f"{svg_path.name}: {element_id} data-pptx-bounds must be "
1186 "'x y width height'"
1187 )
1188 try:
1189 x, y, width, height = (float(part) for part in parts)
1190 except ValueError as exc:
1191 raise TemplateStructureError(
1192 f"{svg_path.name}: {element_id} placeholder bounds must be numeric"
1193 ) from exc
1194 if not all(math.isfinite(value) for value in (x, y, width, height)):
1195 raise TemplateStructureError(
1196 f"{svg_path.name}: {element_id} placeholder bounds must be finite"
1197 )
1198 if width <= 0 or height <= 0:
1199 raise TemplateStructureError(
1200 f"{svg_path.name}: {element_id} placeholder width/height must be positive"
1201 )
1202 return x, y, width, height
1203
1204
1205 def _parse_placeholder_idx(
1206 raw: str | None,
1207 *,
1208 svg_path: Path,
1209 element_id: str,
1210 ) -> int | None:
1211 if raw is None:
1212 return None
1213 value = raw.strip()
1214 if not value or not value.isdigit():
1215 raise TemplateStructureError(
1216 f"{svg_path.name}: {element_id} data-pptx-idx must be "
1217 "a non-negative integer"
1218 )
1219 parsed = int(value)
1220 if parsed > OOXML_UINT32_MAX:
1221 raise TemplateStructureError(
1222 f"{svg_path.name}: {element_id} data-pptx-idx must be "
1223 f"at most {OOXML_UINT32_MAX}"
1224 )
1225 return parsed
1226
1227
1228 def _validate_placeholder_carrier(
1229 carrier: ET.Element,
1230 placeholder: str,
1231 *,
1232 svg_path: Path,
1233 element_id: str,
1234 ) -> None:
1235 tag = _local_tag(carrier)
1236 if placeholder in _TEXT_PLACEHOLDERS and tag != "text":
1237 raise TemplateStructureError(
1238 f"{svg_path.name}: {element_id} placeholder '{placeholder}' must be "
1239 "carried by one direct <text> child"
1240 )
1241 picture_carrier = (
1242 tag in {"image", "svg"}
1243 or (tag == "g" and is_picture_effect_carrier(carrier))
1244 )
1245 if placeholder == "picture" and not picture_carrier:
1246 raise TemplateStructureError(
1247 f"{svg_path.name}: {element_id} picture placeholder must be declared "
1248 "with one direct <image>, crop <svg>, or exact clipped-picture "
1249 "effect carrier"
1250 )
1251 if placeholder == "media" and not picture_carrier:
1252 raise TemplateStructureError(
1253 f"{svg_path.name}: {element_id} media placeholder must be declared "
1254 "with one direct <image>, crop <svg>, or exact clipped-picture "
1255 "effect carrier"
1256 )
1257 if (
1258 placeholder == "object"
1259 and tag not in _OBJECT_PLACEHOLDER_TAGS
1260 and not _is_authored_preset_atom(carrier)
1261 ):
1262 raise TemplateStructureError(
1263 f"{svg_path.name}: {element_id} object placeholder carrier must be "
1264 "one direct text, image, basic SVG shape, or authored preset atom"
1265 )
1266 if placeholder in {"chart", "table"}:
1267 try:
1268 native_kind = native_replacement_kind(carrier)
1269 except NativeMarkerAttributeError as exc:
1270 raise TemplateStructureError(
1271 f"{svg_path.name}: {element_id} placeholder '{placeholder}' has "
1272 f"conflicting chart/table replacement metadata: {exc}"
1273 ) from exc
1274 if tag != "g" or native_kind != placeholder:
1275 raise TemplateStructureError(
1276 f"{svg_path.name}: {element_id} placeholder '{placeholder}' must be "
1277 f"carried by one direct <g data-pptx-replace-with=\"{placeholder}\"> "
1278 "marker"
1279 )
1280
1281
1282 def _structure_attrs(elem: ET.Element) -> list[str]:
1283 return sorted(attr for attr in _STRUCTURE_ATTRS if elem.get(attr) is not None)
1284
1285
1286 def _parse_root_boolean(
1287 root: ET.Element,
1288 attribute: str,
1289 *,
1290 svg_path: Path,
1291 ) -> bool:
1292 """Parse one optional root boolean with a backward-compatible true default."""
1293 raw = root.get(attribute)
1294 if raw is None:
1295 return True
1296 if raw not in {"true", "false"}:
1297 raise TemplateStructureError(
1298 f"{svg_path.name}: root {attribute} must be exactly 'true' or 'false'"
1299 )
1300 return raw == "true"
1301
1302
1303 def parse_template_slide(
1304 svg_path: Path,
1305 slide_num: int,
1306 *,
1307 structured: bool = True,
1308 ) -> TemplateSlideSpec:
1309 """Parse one SVG's explicit template layout and structure elements."""
1310 try:
1311 root = _parse_svg_root(svg_path)
1312 except (OSError, ET.ParseError, NativePayloadError) as exc:
1313 raise TemplateStructureError(
1314 f"{svg_path.name}: unable to parse SVG structure metadata: {exc}"
1315 ) from exc
1316
1317 try:
1318 materialize_inline_geometry_properties(root)
1319 except GeometryStyleError as exc:
1320 raise TemplateStructureError(
1321 f"{svg_path.name}: invalid inline geometry: {exc}"
1322 ) from exc
1323
1324 if _local_tag(root) != "svg":
1325 raise TemplateStructureError(f"{svg_path.name}: root element must be <svg>")
1326 try:
1327 parse_project_viewbox(
1328 root.get("viewBox"),
1329 context=f"{svg_path.name} root viewBox",
1330 )
1331 except CanvasContractError as exc:
1332 raise TemplateStructureError(str(exc)) from exc
1333
1334 geometry_errors = project_geometry_length_errors(root)
1335 if geometry_errors:
1336 preview = "; ".join(geometry_errors[:8])
1337 suffix = (
1338 "" if len(geometry_errors) <= 8
1339 else f"; +{len(geometry_errors) - 8} more"
1340 )
1341 raise TemplateStructureError(
1342 f"{svg_path.name}: invalid project geometry length(s): "
1343 f"{preview}{suffix}"
1344 )
1345
1346 master_key = (root.get("data-pptx-master") or "").strip()
1347 master_name = (root.get("data-pptx-master-name") or "").strip()
1348 if structured and not master_key:
1349 raise TemplateStructureError(
1350 f"{svg_path.name}: structured export requires root data-pptx-master"
1351 )
1352 if structured and not master_name:
1353 raise TemplateStructureError(
1354 f"{svg_path.name}: structured export requires root data-pptx-master-name"
1355 )
1356 if not master_key:
1357 master_key = "preserved-source"
1358 if not master_name:
1359 master_name = "Preserved Source Master"
1360 if not _MASTER_KEY_RE.fullmatch(master_key):
1361 raise TemplateStructureError(
1362 f"{svg_path.name}: invalid data-pptx-master {master_key!r}; use 1-64 "
1363 "ASCII letters, digits, dots, underscores, or hyphens"
1364 )
1365
1366 layout_key = (root.get("data-pptx-layout") or "").strip()
1367 if not layout_key:
1368 raise TemplateStructureError(
1369 f"{svg_path.name}: explicit Layout export requires root data-pptx-layout"
1370 )
1371 if not _LAYOUT_KEY_RE.fullmatch(layout_key):
1372 raise TemplateStructureError(
1373 f"{svg_path.name}: invalid data-pptx-layout {layout_key!r}; use 1-64 "
1374 "ASCII letters, digits, dots, underscores, or hyphens"
1375 )
1376 layout_name = (root.get("data-pptx-layout-name") or "").strip()
1377 if structured and not layout_name:
1378 raise TemplateStructureError(
1379 f"{svg_path.name}: structured export requires root data-pptx-layout-name"
1380 )
1381 if not layout_name:
1382 layout_name = re.sub(r"[-_.]+", " ", layout_key).strip().title() or layout_key
1383 layout_show_master_shapes = _parse_root_boolean(
1384 root,
1385 "data-pptx-show-master-shapes",
1386 svg_path=svg_path,
1387 )
1388 slide_show_inherited_shapes = _parse_root_boolean(
1389 root,
1390 "data-pptx-show-inherited-shapes",
1391 svg_path=svg_path,
1392 )
1393 if structured and root.get("data-pptx-layout-kind") is not None:
1394 raise TemplateStructureError(
1395 f"{svg_path.name}: data-pptx-layout-kind is obsolete; the root "
1396 "Master/Layout identity is already final"
1397 )
1398
1399 illegal_root_attrs = sorted(
1400 attr for attr in _STRUCTURE_ATTRS
1401 if (
1402 attr not in {
1403 "data-pptx-layout",
1404 "data-pptx-layout-name",
1405 "data-pptx-master",
1406 "data-pptx-master-name",
1407 "data-pptx-show-inherited-shapes",
1408 "data-pptx-show-master-shapes",
1409 }
1410 and root.get(attr) is not None
1411 )
1412 )
1413 if illegal_root_attrs:
1414 raise TemplateStructureError(
1415 f"{svg_path.name}: root <svg> cannot use {', '.join(illegal_root_attrs)}"
1416 )
1417
1418 id_counts: dict[str, int] = {}
1419 for elem in root.iter():
1420 element_id = elem.get("id")
1421 if element_id:
1422 id_counts[element_id] = id_counts.get(element_id, 0) + 1
1423 duplicate_ids = sorted(element_id for element_id, count in id_counts.items() if count > 1)
1424 if duplicate_ids:
1425 raise TemplateStructureError(
1426 f"{svg_path.name}: duplicate SVG id(s) are not allowed in explicit Layout mode: "
1427 + ", ".join(duplicate_ids)
1428 )
1429
1430 elements: list[TemplateElementSpec] = []
1431 canvas = _svg_canvas(root)
1432 last_order_rank = -1
1433 visual_order = 0
1434 for elem in root:
1435 tag = _local_tag(elem)
1436 if tag in _NON_VISUAL_TAGS:
1437 continue
1438
1439 element_id = (elem.get("id") or "").strip()
1440 layer_raw = elem.get("data-pptx-layer")
1441 layer = (layer_raw or "").strip().lower() or None
1442 placeholder_raw = elem.get("data-pptx-placeholder")
1443 placeholder = (
1444 (placeholder_raw or "").strip().lower() or None
1445 )
1446 bounds_raw = elem.get("data-pptx-bounds")
1447 placeholder_idx_raw = elem.get("data-pptx-idx")
1448 binding_raw = elem.get("data-pptx-binding")
1449 carrier_raw = elem.get("data-pptx-carrier")
1450 editable_raw = elem.get("data-pptx-editable")
1451 is_background = _is_full_canvas_solid_rect(elem, canvas)
1452 effective_layer = layer or ("slide" if is_background else None)
1453
1454 if (
1455 elem.get("data-pptx-layout") is not None
1456 or elem.get("data-pptx-layout-name") is not None
1457 or elem.get("data-pptx-master") is not None
1458 or elem.get("data-pptx-master-name") is not None
1459 or elem.get("data-pptx-show-inherited-shapes") is not None
1460 or elem.get("data-pptx-show-master-shapes") is not None
1461 ):
1462 raise TemplateStructureError(
1463 f"{svg_path.name}: Master/Layout identity and visibility "
1464 "attributes belong on the root <svg> only"
1465 )
1466 if layer and layer not in _LAYERS:
1467 raise TemplateStructureError(
1468 f"{svg_path.name}: {element_id or tag} has unsupported "
1469 f"data-pptx-layer={layer!r}"
1470 )
1471 if layer_raw is not None and layer is None:
1472 raise TemplateStructureError(
1473 f"{svg_path.name}: {element_id or tag} has empty data-pptx-layer"
1474 )
1475 if placeholder and placeholder not in _PLACEHOLDERS:
1476 raise TemplateStructureError(
1477 f"{svg_path.name}: {element_id or tag} has unsupported "
1478 f"data-pptx-placeholder={placeholder!r}"
1479 )
1480 if placeholder_raw is not None and placeholder is None:
1481 raise TemplateStructureError(
1482 f"{svg_path.name}: {element_id or tag} has empty "
1483 "data-pptx-placeholder"
1484 )
1485 if effective_layer and placeholder:
1486 raise TemplateStructureError(
1487 f"{svg_path.name}: {element_id or tag} cannot be both a static "
1488 "structure/background layer and a content placeholder"
1489 )
1490 if layer == "slide" and not is_background:
1491 raise TemplateStructureError(
1492 f"{svg_path.name}: data-pptx-layer='slide' is allowed only on a "
1493 "direct full-canvas solid background rect"
1494 )
1495 if (
1496 structured
1497 and layer in {"master", "layout"}
1498 and tag == "g"
1499 and not _is_authored_preset_atom(elem)
1500 and not is_picture_effect_carrier(elem)
1501 ):
1502 raise TemplateStructureError(
1503 f"{svg_path.name}: {element_id or tag} is a <g> on the {layer} "
1504 "layer; Master/Layout fixed elements must be root-level atoms"
1505 )
1506 if placeholder_idx_raw is not None and not placeholder:
1507 raise TemplateStructureError(
1508 f"{svg_path.name}: {element_id or tag} has placeholder idx without "
1509 "data-pptx-placeholder"
1510 )
1511 if binding_raw is not None and not placeholder:
1512 raise TemplateStructureError(
1513 f"{svg_path.name}: {element_id or tag} has placeholder binding "
1514 "without data-pptx-placeholder"
1515 )
1516 if carrier_raw is not None:
1517 raise TemplateStructureError(
1518 f"{svg_path.name}: {element_id or tag} declares "
1519 "data-pptx-carrier on a root child; the marker belongs "
1520 "on the direct child inside a placeholder <g>"
1521 )
1522 if (effective_layer or placeholder) and not element_id:
1523 raise TemplateStructureError(
1524 f"{svg_path.name}: direct <{tag}> with Layout metadata requires an id"
1525 )
1526 if editable_raw is not None:
1527 if not effective_layer or editable_raw.strip().lower() != "false":
1528 raise TemplateStructureError(
1529 f"{svg_path.name}: data-pptx-editable currently supports only "
1530 "'false' on master/layout elements or slide backgrounds"
1531 )
1532
1533 if is_background:
1534 order_rank = {"master": 0, "layout": 1, "slide": 2}[effective_layer]
1535 elif effective_layer == "master":
1536 order_rank = 3
1537 elif effective_layer == "layout":
1538 order_rank = 4
1539 else:
1540 order_rank = 5
1541 if order_rank < last_order_rank:
1542 raise TemplateStructureError(
1543 f"{svg_path.name}: {element_id or tag} violates template paint order; "
1544 "use Master background, Layout background, Slide background, "
1545 "Master shapes, Layout shapes, then Slide content/placeholders"
1546 )
1547 last_order_rank = order_rank
1548
1549 placeholder_bounds = _parse_placeholder_bounds(
1550 bounds_raw if placeholder else None,
1551 svg_path=svg_path,
1552 element_id=element_id or tag,
1553 )
1554 if structured and placeholder and placeholder_bounds is None:
1555 raise TemplateStructureError(
1556 f"{svg_path.name}: Layout placeholder {element_id!r} requires "
1557 "explicit data-pptx-bounds; define the "
1558 "reusable frame from the design zone, not the current text bounds"
1559 )
1560 placeholder_idx = _parse_placeholder_idx(
1561 placeholder_idx_raw,
1562 svg_path=svg_path,
1563 element_id=element_id or tag,
1564 )
1565
1566 placeholder_binding: str | None = None
1567 placeholder_carrier_tag: str | None = None
1568 if placeholder and structured:
1569 if tag != "g":
1570 raise TemplateStructureError(
1571 f"{svg_path.name}: placeholder {element_id!r} must be declared "
1572 "on a root-level <g> authoring boundary"
1573 )
1574 wrapper_visual_attrs = sorted(
1575 name
1576 for name in elem.attrib
1577 if name != "id" and not name.startswith("data-pptx-")
1578 )
1579 if wrapper_visual_attrs:
1580 raise TemplateStructureError(
1581 f"{svg_path.name}: placeholder group {element_id!r} must be a "
1582 "render-neutral authoring boundary; move these attributes to "
1583 "its content: " + ", ".join(wrapper_visual_attrs)
1584 )
1585 placeholder_binding = (binding_raw or "carrier").strip().lower()
1586 if binding_raw is not None and not binding_raw.strip():
1587 raise TemplateStructureError(
1588 f"{svg_path.name}: placeholder {element_id!r} has an empty "
1589 "data-pptx-binding"
1590 )
1591 if placeholder_binding not in PLACEHOLDER_BINDING_MODES:
1592 allowed = ", ".join(sorted(PLACEHOLDER_BINDING_MODES))
1593 raise TemplateStructureError(
1594 f"{svg_path.name}: placeholder {element_id!r} binding must be "
1595 f"one of: {allowed}"
1596 )
1597 visual_children = [
1598 child for child in elem if _local_tag(child) not in _NON_VISUAL_TAGS
1599 ]
1600 carrier_children = [
1601 child
1602 for child in visual_children
1603 if (child.get("data-pptx-carrier") or "")
1604 .strip()
1605 .lower()
1606 == "true"
1607 ]
1608 for child in elem:
1609 marker = child.get("data-pptx-carrier")
1610 if marker is not None and marker.strip().lower() != "true":
1611 raise TemplateStructureError(
1612 f"{svg_path.name}: placeholder {element_id!r} carrier marker "
1613 "must be exactly 'true'"
1614 )
1615 illegal_child_attrs = [
1616 attr
1617 for attr in _structure_attrs(child)
1618 if attr != "data-pptx-carrier"
1619 ]
1620 if illegal_child_attrs:
1621 raise TemplateStructureError(
1622 f"{svg_path.name}: placeholder {element_id!r} child uses "
1623 "nested structure metadata: " + ", ".join(illegal_child_attrs)
1624 )
1625 for descendant in child.iter():
1626 if descendant is child:
1627 continue
1628 nested_attrs = _structure_attrs(descendant)
1629 if nested_attrs:
1630 nested_id = descendant.get("id") or _local_tag(descendant)
1631 raise TemplateStructureError(
1632 f"{svg_path.name}: {nested_id} uses nested structure "
1633 "metadata: " + ", ".join(nested_attrs)
1634 )
1635 if placeholder_binding == "proxy":
1636 if placeholder != "object":
1637 raise TemplateStructureError(
1638 f"{svg_path.name}: placeholder {element_id!r} may use proxy "
1639 "binding only with data-pptx-placeholder='object'"
1640 )
1641 if carrier_children:
1642 raise TemplateStructureError(
1643 f"{svg_path.name}: proxy placeholder {element_id!r} must not "
1644 "declare a carrier child"
1645 )
1646 if not visual_children:
1647 raise TemplateStructureError(
1648 f"{svg_path.name}: proxy placeholder {element_id!r} must "
1649 "contain visible Slide-local content"
1650 )
1651 else:
1652 if len(visual_children) != 1 or len(carrier_children) != 1:
1653 composite_hint = (
1654 " For composite object content, declare "
1655 "data-pptx-binding='proxy' in the prototype "
1656 "and page, or create an adaptive Layout; never add a tiny "
1657 "or transparent dummy carrier."
1658 if placeholder == "object" else ""
1659 )
1660 raise TemplateStructureError(
1661 f"{svg_path.name}: carrier placeholder {element_id!r} must "
1662 "contain exactly one visual direct child and mark it "
1663 "data-pptx-carrier='true'."
1664 f"{composite_hint}"
1665 )
1666 carrier = carrier_children[0]
1667 placeholder_carrier_tag = _local_tag(carrier)
1668 _validate_placeholder_carrier(
1669 carrier,
1670 placeholder,
1671 svg_path=svg_path,
1672 element_id=element_id,
1673 )
1674 elif placeholder:
1675 placeholder_binding = "carrier"
1676 placeholder_carrier_tag = tag
1677 _validate_placeholder_carrier(
1678 elem,
1679 placeholder,
1680 svg_path=svg_path,
1681 element_id=element_id,
1682 )
1683 else:
1684 for descendant in elem.iter():
1685 if descendant is elem:
1686 continue
1687 nested_attrs = _structure_attrs(descendant)
1688 if nested_attrs:
1689 nested_id = descendant.get("id") or _local_tag(descendant)
1690 raise TemplateStructureError(
1691 f"{svg_path.name}: {nested_id} uses structure metadata below "
1692 "the SVG root: " + ", ".join(nested_attrs)
1693 )
1694
1695 if effective_layer or placeholder:
1696 elements.append(TemplateElementSpec(
1697 element_id=element_id,
1698 order=visual_order,
1699 tag=tag,
1700 layer=effective_layer,
1701 placeholder=placeholder,
1702 placeholder_bounds=placeholder_bounds,
1703 placeholder_idx=placeholder_idx,
1704 placeholder_binding=placeholder_binding,
1705 placeholder_carrier_tag=placeholder_carrier_tag,
1706 is_background=is_background,
1707 ))
1708 visual_order += 1
1709
1710 for scope in ("master", "layout", "slide"):
1711 backgrounds = [
1712 item for item in elements
1713 if item.layer == scope and item.is_background
1714 ]
1715 if len(backgrounds) > 1:
1716 raise TemplateStructureError(
1717 f"{svg_path.name}: explicit Layout mode allows at most one {scope} "
1718 "solid background"
1719 )
1720
1721 spec = TemplateSlideSpec(
1722 slide_num=slide_num,
1723 svg_path=svg_path,
1724 master_key=master_key,
1725 master_name=master_name,
1726 layout_key=layout_key,
1727 layout_name=layout_name,
1728 layout_show_master_shapes=layout_show_master_shapes,
1729 slide_show_inherited_shapes=slide_show_inherited_shapes,
1730 elements=tuple(elements),
1731 )
1732 return spec
1733
1734
1735 def _validate_template_slide_contracts(
1736 specs: list[TemplateSlideSpec],
1737 ) -> None:
1738 """Enforce cross-prototype Master and Layout structure identity."""
1739 by_master: dict[str, list[TemplateSlideSpec]] = {}
1740 for spec in specs:
1741 by_master.setdefault(spec.master_key, []).append(spec)
1742 for master_key, master_specs in by_master.items():
1743 prototype = master_specs[0]
1744 expected_master = tuple(
1745 item.contract_signature() for item in prototype.master_elements
1746 )
1747 for spec in master_specs[1:]:
1748 if spec.master_name != prototype.master_name:
1749 raise TemplateStructureError(
1750 f"{spec.svg_path.name}: Master {master_key!r} uses name "
1751 f"{spec.master_name!r}, expected {prototype.master_name!r}"
1752 )
1753 actual_master = tuple(
1754 item.contract_signature() for item in spec.master_elements
1755 )
1756 if actual_master != expected_master:
1757 raise TemplateStructureError(
1758 f"{spec.svg_path.name}: Master {master_key!r} contract differs "
1759 f"from {prototype.svg_path.name}; slides sharing one Master must "
1760 "repeat the same root-level atoms in the same order"
1761 )
1762
1763 by_layout: dict[str, list[TemplateSlideSpec]] = {}
1764 for spec in specs:
1765 by_layout.setdefault(spec.layout_key, []).append(spec)
1766 for layout_key, layout_specs in by_layout.items():
1767 prototype = layout_specs[0]
1768 template_placeholder_bindings(prototype)
1769 for spec in layout_specs[1:]:
1770 if spec.layout_name != prototype.layout_name:
1771 raise TemplateStructureError(
1772 f"{spec.svg_path.name}: layout {layout_key!r} uses name "
1773 f"{spec.layout_name!r}, expected {prototype.layout_name!r}"
1774 )
1775 if spec.master_key != prototype.master_key:
1776 raise TemplateStructureError(
1777 f"{spec.svg_path.name}: globally unique layout {layout_key!r} "
1778 f"belongs to Master {spec.master_key!r}, expected "
1779 f"{prototype.master_key!r}"
1780 )
1781 if (
1782 spec.layout_show_master_shapes
1783 != prototype.layout_show_master_shapes
1784 ):
1785 raise TemplateStructureError(
1786 f"{spec.svg_path.name}: layout {layout_key!r} uses "
1787 "data-pptx-show-master-shapes="
1788 f"{str(spec.layout_show_master_shapes).lower()}, expected "
1789 f"{str(prototype.layout_show_master_shapes).lower()}"
1790 )
1791 if spec.layout_contract != prototype.layout_contract:
1792 raise TemplateStructureError(
1793 f"{spec.svg_path.name}: layout {layout_key!r} structure differs "
1794 f"from prototype {prototype.svg_path.name}; repeat the same layout "
1795 "layers and placeholder ids/types in the same order"
1796 )
1797
1798
1799 def parse_template_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]:
1800 """Parse a deck and enforce cross-slide master/layout contracts."""
1801 specs = [
1802 parse_template_slide(svg_path, slide_num)
1803 for slide_num, svg_path in enumerate(svg_files, start=1)
1804 ]
1805 if not specs:
1806 raise TemplateStructureError(
1807 "Explicit Layout export requires at least one SVG slide"
1808 )
1809 _validate_template_slide_contracts(specs)
1810 return specs
1811
1812
1813 def parse_optional_layout_slides(
1814 svg_files: list[Path],
1815 ) -> list[TemplateSlideSpec] | None:
1816 """Parse an all-or-none structured Layout contract, or return no metadata."""
1817 roots: list[tuple[Path, ET.Element]] = []
1818 has_structure_metadata = False
1819 for svg_path in svg_files:
1820 try:
1821 root = _parse_svg_root(svg_path)
1822 except (OSError, ET.ParseError, NativePayloadError) as exc:
1823 raise TemplateStructureError(
1824 f"{svg_path.name}: unable to inspect SVG Layout metadata: {exc}"
1825 ) from exc
1826 roots.append((svg_path, root))
1827 has_structure_metadata = has_structure_metadata or any(
1828 elem.get(attr) is not None
1829 for elem in root.iter()
1830 for attr in _STRUCTURE_ATTRS
1831 )
1832
1833 if not has_structure_metadata:
1834 return None
1835
1836 missing_master_keys = [
1837 svg_path.name
1838 for svg_path, root in roots
1839 if not (root.get("data-pptx-master") or "").strip()
1840 ]
1841 missing_master_names = [
1842 svg_path.name
1843 for svg_path, root in roots
1844 if not (root.get("data-pptx-master-name") or "").strip()
1845 ]
1846 missing_keys = [
1847 svg_path.name
1848 for svg_path, root in roots
1849 if not (root.get("data-pptx-layout") or "").strip()
1850 ]
1851 missing_names = [
1852 svg_path.name
1853 for svg_path, root in roots
1854 if not (root.get("data-pptx-layout-name") or "").strip()
1855 ]
1856 if missing_master_keys or missing_master_names or missing_keys or missing_names:
1857 missing_fields: list[str] = []
1858 if missing_master_keys:
1859 missing_fields.append(
1860 "data-pptx-master: " + ", ".join(missing_master_keys)
1861 )
1862 if missing_master_names:
1863 missing_fields.append(
1864 "data-pptx-master-name: " + ", ".join(missing_master_names)
1865 )
1866 if missing_keys:
1867 missing_fields.append(
1868 "data-pptx-layout: " + ", ".join(missing_keys)
1869 )
1870 if missing_names:
1871 missing_fields.append(
1872 "data-pptx-layout-name: " + ", ".join(missing_names)
1873 )
1874 raise TemplateStructureError(
1875 "Explicit Layout metadata is all-or-none: once any SVG uses PPTX "
1876 "structure metadata, every generated page root must declare "
1877 "Master/Layout keys and names with non-empty values; "
1878 "missing "
1879 + "; ".join(missing_fields)
1880 )
1881 return parse_template_slides(svg_files)
1882
1883
1884 def flat_structure_metadata_errors(svg_files: list[Path]) -> list[str]:
1885 """Return every Master/Layout marker forbidden by flat export."""
1886 errors: list[str] = []
1887 for svg_path in svg_files:
1888 try:
1889 root = _parse_svg_root(svg_path)
1890 except (OSError, ET.ParseError, NativePayloadError) as exc:
1891 errors.append(
1892 f"{svg_path.name}: unable to inspect flat-mode metadata: {exc}"
1893 )
1894 continue
1895 for elem in root.iter():
1896 attrs = sorted(
1897 attr
1898 for attr in _FLAT_FORBIDDEN_STRUCTURE_ATTRS
1899 if elem.get(attr) is not None
1900 )
1901 if not attrs:
1902 continue
1903 element_id = (elem.get("id") or _local_tag(elem)).strip()
1904 errors.append(
1905 f"{svg_path.name}: flat mode forbids Master/Layout structure "
1906 f"metadata on {element_id!r}: " + ", ".join(attrs)
1907 )
1908 return errors
1909
1910
1911 def parse_preserve_slides(svg_files: list[Path]) -> list[TemplateSlideSpec]:
1912 """Parse preserve-mode slides before source master grouping is known."""
1913 specs = [
1914 parse_template_slide(svg_path, slide_num, structured=False)
1915 for slide_num, svg_path in enumerate(svg_files, start=1)
1916 ]
1917 if not specs:
1918 raise TemplateStructureError("Preserve export requires at least one SVG slide")
1919 return specs
1920
1921
1922 def structured_layout_definition_files(
1923 specs: list[TemplateSlideSpec],
1924 structure_lock: PptxStructureLock,
1925 ) -> list[Path]:
1926 """Validate the unique Layout roster and return unused prototype SVGs.
1927
1928 A generated page can be the carrier for a used Layout definition. A Layout
1929 with no generated page must point at one installed template SVG; the builder
1930 converts that SVG on an internal trailing slide and removes the carrier slide
1931 after registering the reusable Layout.
1932 """
1933 if structure_lock.mode != "structured":
1934 return []
1935 definitions = {
1936 definition.layout_key: definition
1937 for definition in structure_lock.layout_definitions
1938 }
1939 specs_by_slide = {spec.slide_num: spec for spec in specs}
1940 used_layout_keys = {spec.layout_key for spec in specs}
1941 master_names = {
1942 master.master_key: master.master_name
1943 for master in structure_lock.masters
1944 }
1945 combined_specs = list(specs)
1946 definition_files: list[Path] = []
1947 next_slide_num = max(specs_by_slide, default=0) + 1
1948 for definition in structure_lock.layout_definitions:
1949 if definition.prototype_slide_num is not None:
1950 prototype = specs_by_slide.get(definition.prototype_slide_num)
1951 if prototype is None:
1952 raise TemplateStructureError(
1953 f"spec_lock.md Layout {definition.layout_key!r} uses missing "
1954 f"prototype page P{definition.prototype_slide_num:02d}"
1955 )
1956 elif definition.prototype_svg_path is not None:
1957 prototype = parse_template_slide(
1958 definition.prototype_svg_path,
1959 next_slide_num,
1960 )
1961 next_slide_num += 1
1962 combined_specs.append(prototype)
1963 if definition.layout_key not in used_layout_keys:
1964 definition_files.append(definition.prototype_svg_path)
1965 else:
1966 raise TemplateStructureError(
1967 f"spec_lock.md Layout {definition.layout_key!r} has no prototype"
1968 )
1969 expected_master_name = master_names.get(definition.master_key)
1970 if (
1971 prototype.layout_key != definition.layout_key
1972 or prototype.layout_name != definition.layout_name
1973 or prototype.master_key != definition.master_key
1974 or prototype.master_name != expected_master_name
1975 ):
1976 raise TemplateStructureError(
1977 f"spec_lock.md Layout {definition.layout_key!r} definition does "
1978 f"not match prototype {prototype.svg_path.name} root identity"
1979 )
1980 missing_definitions = sorted(used_layout_keys - set(definitions))
1981 if missing_definitions:
1982 raise TemplateStructureError(
1983 "spec_lock.md pptx_layouts is missing generated Layout key(s): "
1984 + ", ".join(missing_definitions)
1985 )
1986 _validate_template_slide_contracts(combined_specs)
1987 return definition_files
1988
1989
1990 def template_prototype_lock_errors(
1991 structure_lock: PptxStructureLock,
1992 ) -> list[str]:
1993 """Validate selected input prototypes before generated pages exist.
1994
1995 ``page_layouts`` records authoring-input provenance. Strict execution keeps
1996 that prototype's Layout identity, while adaptive execution may declare a
1997 new Layout under the same Master. Final generated SVGs remain subject to
1998 :func:`template_lock_errors` and :func:`template_prototype_errors`.
1999 """
2000 if structure_lock.mode != "structured":
2001 return []
2002
2003 errors: list[str] = []
2004 specs: list[TemplateSlideSpec] = []
2005 for prototype in structure_lock.prototypes:
2006 try:
2007 specs.append(
2008 parse_template_slide(prototype.svg_path, prototype.slide_num)
2009 )
2010 except TemplateStructureError as exc:
2011 errors.append(str(exc))
2012 if errors:
2013 return list(dict.fromkeys(errors))
2014
2015 try:
2016 _validate_template_slide_contracts(specs)
2017 except TemplateStructureError as exc:
2018 errors.append(str(exc))
2019
2020 assignments = {
2021 reference.slide_num: reference
2022 for reference in structure_lock.layouts
2023 }
2024 definitions = {
2025 definition.layout_key: definition
2026 for definition in structure_lock.layout_definitions
2027 }
2028 master_names = {
2029 master.master_key: master.master_name
2030 for master in structure_lock.masters
2031 }
2032 prototype_pages = {spec.slide_num for spec in specs}
2033 assignment_pages = set(assignments)
2034 missing_assignments = sorted(prototype_pages - assignment_pages)
2035 missing_prototypes = sorted(assignment_pages - prototype_pages)
2036 if missing_assignments:
2037 errors.append(
2038 "spec_lock.md page_pptx_layouts is missing generated page(s): "
2039 + ", ".join(
2040 f"P{slide_num:02d}" for slide_num in missing_assignments
2041 )
2042 )
2043 if missing_prototypes:
2044 errors.append(
2045 "spec_lock.md page_layouts is missing generated page(s): "
2046 + ", ".join(f"P{slide_num:02d}" for slide_num in missing_prototypes)
2047 )
2048
2049 adherence = structure_lock.template_adherence or "strict"
2050 for spec in specs:
2051 assignment = assignments.get(spec.slide_num)
2052 if assignment is None:
2053 continue
2054 definition = definitions.get(assignment.layout_key)
2055 if definition is None:
2056 errors.append(
2057 f"spec_lock.md P{spec.slide_num:02d} references undeclared "
2058 f"Layout {assignment.layout_key!r}"
2059 )
2060 continue
2061 expected_master_name = master_names.get(definition.master_key)
2062 if (
2063 spec.master_key != definition.master_key
2064 or spec.master_name != expected_master_name
2065 ):
2066 errors.append(
2067 f"{spec.svg_path.name}: input prototype Master "
2068 f"{spec.master_key!r} / {spec.master_name!r} does not match "
2069 f"assigned Layout {definition.layout_key!r} Master "
2070 f"{definition.master_key!r} / {expected_master_name!r}"
2071 )
2072
2073 reuses_input_layout = spec.layout_key == definition.layout_key
2074 must_match_layout = (
2075 adherence == "strict"
2076 or reuses_input_layout
2077 or definition.prototype_svg_path is not None
2078 )
2079 if must_match_layout and (
2080 spec.layout_key != definition.layout_key
2081 or spec.layout_name != definition.layout_name
2082 ):
2083 errors.append(
2084 f"{spec.svg_path.name}: input prototype Layout "
2085 f"{spec.layout_key!r} / {spec.layout_name!r} does not match "
2086 f"assigned Layout {definition.layout_key!r} / "
2087 f"{definition.layout_name!r}"
2088 )
2089 elif (
2090 adherence == "adaptive"
2091 and not reuses_input_layout
2092 and spec.layout_name == definition.layout_name
2093 ):
2094 errors.append(
2095 f"{spec.svg_path.name}: adaptive output Layout "
2096 f"{definition.layout_key!r} must use a new picker name instead "
2097 f"of input prototype name {spec.layout_name!r}"
2098 )
2099
2100 definition_specs = list(specs)
2101 next_slide_num = max(prototype_pages, default=0) + 1
2102 for definition in structure_lock.layout_definitions:
2103 if definition.prototype_slide_num is not None:
2104 source_assignment = assignments.get(definition.prototype_slide_num)
2105 if source_assignment is None:
2106 errors.append(
2107 f"spec_lock.md Layout {definition.layout_key!r} uses missing "
2108 f"prototype page P{definition.prototype_slide_num:02d}"
2109 )
2110 elif source_assignment.layout_key != definition.layout_key:
2111 errors.append(
2112 f"spec_lock.md Layout {definition.layout_key!r} uses "
2113 f"P{definition.prototype_slide_num:02d}, but that page is "
2114 f"assigned to Layout {source_assignment.layout_key!r}"
2115 )
2116 continue
2117 if definition.prototype_svg_path is None:
2118 errors.append(
2119 f"spec_lock.md Layout {definition.layout_key!r} has no prototype"
2120 )
2121 continue
2122 try:
2123 definition_spec = parse_template_slide(
2124 definition.prototype_svg_path,
2125 next_slide_num,
2126 )
2127 next_slide_num += 1
2128 except TemplateStructureError as exc:
2129 errors.append(str(exc))
2130 continue
2131 definition_specs.append(definition_spec)
2132 expected_master_name = master_names.get(definition.master_key)
2133 if (
2134 definition_spec.layout_key != definition.layout_key
2135 or definition_spec.layout_name != definition.layout_name
2136 or definition_spec.master_key != definition.master_key
2137 or definition_spec.master_name != expected_master_name
2138 ):
2139 errors.append(
2140 f"spec_lock.md Layout {definition.layout_key!r} definition does "
2141 f"not match prototype {definition_spec.svg_path.name} root identity"
2142 )
2143
2144 try:
2145 _validate_template_slide_contracts(definition_specs)
2146 except TemplateStructureError as exc:
2147 errors.append(str(exc))
2148 return list(dict.fromkeys(errors))
2149
2150
2151 def template_lock_errors(
2152 specs: list[TemplateSlideSpec],
2153 structure_lock: PptxStructureLock,
2154 ) -> list[str]:
2155 """Return mismatches between parsed SVG layouts and the project lock."""
2156 if structure_lock.mode not in {"structured", "preserve"}:
2157 return []
2158 errors: list[str] = []
2159 references = {
2160 reference.slide_num: reference
2161 for reference in structure_lock.layouts
2162 }
2163 actual_slides = {spec.slide_num for spec in specs}
2164 expected_slides = set(references)
2165 missing = sorted(actual_slides - expected_slides)
2166 extra = sorted(expected_slides - actual_slides)
2167 assignment_section = (
2168 "page_pptx_layouts"
2169 if structure_lock.mode == "structured"
2170 else "pptx_layouts"
2171 )
2172 if missing:
2173 pages = ", ".join(f"P{slide_num:02d}" for slide_num in missing)
2174 errors.append(
2175 f"spec_lock.md {assignment_section} is missing generated page(s): "
2176 f"{pages}"
2177 )
2178 if extra:
2179 pages = ", ".join(f"P{slide_num:02d}" for slide_num in extra)
2180 errors.append(
2181 f"spec_lock.md {assignment_section} references absent page(s): "
2182 f"{pages}"
2183 )
2184 for spec in specs:
2185 reference = references.get(spec.slide_num)
2186 if reference is None:
2187 continue
2188 if spec.layout_key != reference.layout_key:
2189 errors.append(
2190 f"{spec.svg_path.name}: data-pptx-layout={spec.layout_key!r} "
2191 f"does not match spec_lock P{spec.slide_num:02d} Layout key "
2192 f"{reference.layout_key!r}"
2193 )
2194 if structure_lock.mode == "preserve":
2195 if reference.layout_name and spec.layout_name != reference.layout_name:
2196 errors.append(
2197 f"{spec.svg_path.name}: data-pptx-layout-name="
2198 f"{spec.layout_name!r} does not match spec_lock "
2199 f"P{spec.slide_num:02d} Layout name "
2200 f"{reference.layout_name!r}"
2201 )
2202 if structure_lock.mode == "structured":
2203 definitions = {
2204 definition.layout_key: definition
2205 for definition in structure_lock.layout_definitions
2206 }
2207 master_names = {
2208 master.master_key: master.master_name for master in structure_lock.masters
2209 }
2210 for spec in specs:
2211 definition = definitions.get(spec.layout_key)
2212 if definition is not None:
2213 if spec.layout_name != definition.layout_name:
2214 errors.append(
2215 f"{spec.svg_path.name}: data-pptx-layout-name="
2216 f"{spec.layout_name!r} does not match Layout "
2217 f"{spec.layout_key!r} name {definition.layout_name!r}"
2218 )
2219 if spec.master_key != definition.master_key:
2220 errors.append(
2221 f"{spec.svg_path.name}: data-pptx-master="
2222 f"{spec.master_key!r} does not match Layout "
2223 f"{spec.layout_key!r} Master {definition.master_key!r}"
2224 )
2225 expected_name = master_names.get(spec.master_key)
2226 if expected_name is not None and spec.master_name != expected_name:
2227 errors.append(
2228 f"{spec.svg_path.name}: data-pptx-master-name="
2229 f"{spec.master_name!r} does not match spec_lock Master "
2230 f"{spec.master_key!r} name {expected_name!r}"
2231 )
2232 try:
2233 structured_layout_definition_files(specs, structure_lock)
2234 except TemplateStructureError as exc:
2235 errors.append(str(exc))
2236 return errors
2237
2238
2239 def _signature_attr_value(
2240 name: str,
2241 value: str,
2242 *,
2243 svg_path: Path | None,
2244 asset_identity: bool,
2245 ) -> str:
2246 """Normalize portable asset references without weakening visual identity."""
2247 if name.rsplit("}", 1)[-1] != "href":
2248 return value
2249 if asset_identity:
2250 if value.startswith("#") or "://" in value:
2251 return value
2252 if value.startswith("data:"):
2253 return "data-sha256:" + hashlib.sha256(
2254 value.encode("utf-8")
2255 ).hexdigest()
2256 if svg_path is None:
2257 raise TemplateStructureError(
2258 "literal asset comparison requires the source SVG path"
2259 )
2260 asset_path = (svg_path.parent / value).resolve()
2261 if not asset_path.is_file():
2262 raise TemplateStructureError(
2263 f"{svg_path.name}: mirror asset reference does not resolve: "
2264 f"{value!r}"
2265 )
2266 return "file-sha256:" + _file_sha256(asset_path)
2267 if value.startswith("data:") or "://" in value:
2268 return value
2269 return value.replace("\\", "/").rsplit("/", 1)[-1]
2270
2271
2272 def _element_tree_signature(
2273 elem: ET.Element,
2274 *,
2275 include_skin: bool = False,
2276 include_text: bool = True,
2277 svg_path: Path | None = None,
2278 asset_identity: bool = False,
2279 ignore_structure_attrs: bool = False,
2280 ) -> tuple[object, ...]:
2281 """Return a stable structural or literal-visual SVG subtree signature."""
2282 text = (elem.text or "") if include_text else ""
2283 if _local_tag(elem) not in {"text", "tspan"} and not text.strip():
2284 text = ""
2285 attrs = tuple(sorted(
2286 (
2287 name,
2288 _signature_attr_value(
2289 name,
2290 value,
2291 svg_path=svg_path,
2292 asset_identity=asset_identity,
2293 ),
2294 )
2295 for name, value in elem.attrib.items()
2296 if (
2297 not (
2298 ignore_structure_attrs
2299 and name.rsplit("}", 1)[-1] in _STRUCTURE_ATTRS
2300 )
2301 and (
2302 include_skin
2303 or name.rsplit("}", 1)[-1] not in _TEMPLATE_SKIN_ATTRS
2304 )
2305 )
2306 ))
2307 return (
2308 elem.tag,
2309 attrs,
2310 text,
2311 tuple(
2312 _element_tree_signature(
2313 child,
2314 include_skin=include_skin,
2315 include_text=include_text,
2316 svg_path=svg_path,
2317 asset_identity=asset_identity,
2318 ignore_structure_attrs=ignore_structure_attrs,
2319 )
2320 for child in elem
2321 ),
2322 )
2323
2324
2325 def _svg_reference_ids(elem: ET.Element) -> set[str]:
2326 """Return fragment ids referenced anywhere in one SVG subtree."""
2327 references: set[str] = set()
2328 for node in elem.iter():
2329 for name, value in node.attrib.items():
2330 if name.rsplit("}", 1)[-1] == "href" and value.startswith("#"):
2331 references.add(value[1:])
2332 references.update(
2333 match.group(2)[1:]
2334 for match in _CSS_URL_RE.finditer(value)
2335 if match.group(2).startswith("#")
2336 )
2337 return references
2338
2339
2340 def _font_family_names(raw_value: str) -> set[str]:
2341 """Return normalized CSS font-family names from one declaration value."""
2342 return {
2343 value.strip().strip("'\"")
2344 for value in raw_value.split(",")
2345 if value.strip().strip("'\"")
2346 }
2347
2348
2349 def _font_families_from_declarations(raw: str) -> set[str]:
2350 """Return font families assigned by one inline or stylesheet declaration."""
2351 families: set[str] = set()
2352 for match in re.finditer(
2353 r"font-family\s*:\s*([^;{}]+)",
2354 raw,
2355 flags=re.IGNORECASE,
2356 ):
2357 families.update(_font_family_names(match.group(1)))
2358 return families
2359
2360
2361 def _scope_selector_tokens(
2362 root: ET.Element,
2363 elements: tuple[ET.Element, ...],
2364 ) -> tuple[set[str], set[str], set[str], list[dict[str, str]], set[str]]:
2365 """Collect the small selector vocabulary needed to filter SVG CSS rules."""
2366 ids: set[str] = set()
2367 classes: set[str] = set()
2368 tags: set[str] = set()
2369 attributes: list[dict[str, str]] = []
2370 font_families: set[str] = set()
2371 nodes = [root]
2372 for element in elements:
2373 nodes.extend(element.iter())
2374 for node in nodes:
2375 tags.add(_local_tag(node))
2376 node_id = (node.get("id") or "").strip()
2377 if node_id:
2378 ids.add(node_id)
2379 classes.update((node.get("class") or "").split())
2380 local_attrs = {
2381 name.rsplit("}", 1)[-1]: value
2382 for name, value in node.attrib.items()
2383 }
2384 attributes.append(local_attrs)
2385 if local_attrs.get("font-family"):
2386 font_families.update(
2387 _font_family_names(local_attrs["font-family"])
2388 )
2389 if local_attrs.get("style"):
2390 font_families.update(
2391 _font_families_from_declarations(local_attrs["style"])
2392 )
2393 return ids, classes, tags, attributes, font_families
2394
2395
2396 def _css_selector_matches_scope(
2397 selector: str,
2398 *,
2399 ids: set[str],
2400 classes: set[str],
2401 tags: set[str],
2402 attributes: list[dict[str, str]],
2403 ) -> bool:
2404 """Conservatively decide whether one simple SVG selector can affect scope."""
2405 selector_ids = set(_CSS_ID_RE.findall(selector))
2406 if selector_ids and not selector_ids.issubset(ids):
2407 return False
2408 selector_classes = set(_CSS_CLASS_RE.findall(selector))
2409 if selector_classes and not selector_classes.issubset(classes):
2410 return False
2411 for match in _CSS_ATTR_RE.finditer(selector):
2412 attr_name = match.group(1).rsplit(":", 1)[-1]
2413 expected = (match.group(3) or "").strip()
2414 if not any(
2415 attr_name in attrs
2416 and (not expected or attrs[attr_name].strip() == expected)
2417 for attrs in attributes
2418 ):
2419 return False
2420 selector_tags = {
2421 tag.lower()
2422 for tag in _CSS_TAG_RE.findall(selector)
2423 if tag != "*"
2424 }
2425 if selector_tags and not selector_tags.issubset(
2426 {tag.lower() for tag in tags}
2427 ):
2428 return False
2429 return True
2430
2431
2432 def _css_asset_signature(value: str, svg_path: Path) -> str:
2433 """Replace CSS URL assets with byte identities while retaining fragments."""
2434 def replace(match: re.Match[str]) -> str:
2435 target = match.group(2).strip()
2436 if target.startswith("#"):
2437 return f"url({target})"
2438 identity = _signature_attr_value(
2439 "href",
2440 target,
2441 svg_path=svg_path,
2442 asset_identity=True,
2443 )
2444 return f"url({identity})"
2445
2446 return _CSS_URL_RE.sub(replace, value)
2447
2448
2449 def _normalize_css_declarations(raw: str, svg_path: Path) -> str:
2450 """Normalize formatting-only CSS differences without changing cascade order."""
2451 declarations: list[str] = []
2452 for raw_declaration in raw.split(";"):
2453 declaration = raw_declaration.strip()
2454 if not declaration:
2455 continue
2456 if ":" not in declaration:
2457 declarations.append(" ".join(declaration.split()))
2458 continue
2459 name, value = declaration.split(":", 1)
2460 normalized_value = " ".join(
2461 _css_asset_signature(value.strip(), svg_path).split()
2462 )
2463 declarations.append(f"{name.strip().lower()}:{normalized_value}")
2464 return ";".join(declarations)
2465
2466
2467 def _scope_css_signature(
2468 root: ET.Element,
2469 elements: tuple[ET.Element, ...],
2470 svg_path: Path,
2471 ) -> tuple[tuple[str, str], ...]:
2472 """Return only stylesheet rules that can affect the selected visual scope."""
2473 ids, classes, tags, attributes, font_families = _scope_selector_tokens(
2474 root,
2475 elements,
2476 )
2477 parsed_rules: list[tuple[str, str]] = []
2478 for style in root.iter():
2479 if _local_tag(style) != "style":
2480 continue
2481 css = _CSS_COMMENT_RE.sub("", style.text or "")
2482 for match in _CSS_RULE_RE.finditer(css):
2483 parsed_rules.append((match.group(1).strip(), match.group(2)))
2484
2485 matched_selectors: dict[int, tuple[str, ...]] = {}
2486 for index, (raw_selector, body) in enumerate(parsed_rules):
2487 if raw_selector.startswith("@"):
2488 continue
2489 selectors = tuple(
2490 " ".join(selector.split())
2491 for selector in raw_selector.split(",")
2492 if _css_selector_matches_scope(
2493 selector,
2494 ids=ids,
2495 classes=classes,
2496 tags=tags,
2497 attributes=attributes,
2498 )
2499 )
2500 if selectors:
2501 matched_selectors[index] = selectors
2502 font_families.update(_font_families_from_declarations(body))
2503
2504 rules: list[tuple[str, str]] = []
2505 for index, (raw_selector, body) in enumerate(parsed_rules):
2506 selectors = matched_selectors.get(index)
2507 if selectors is None:
2508 if not raw_selector.lower().startswith("@font-face"):
2509 continue
2510 declared_families = _font_families_from_declarations(body)
2511 if not declared_families.intersection(font_families):
2512 continue
2513 selectors = ("@font-face",)
2514 rules.append((
2515 ",".join(selectors),
2516 _normalize_css_declarations(body, svg_path),
2517 ))
2518 return tuple(rules)
2519
2520
2521 def _scope_visual_resources_signature(
2522 root: ET.Element,
2523 elements: tuple[ET.Element, ...],
2524 svg_path: Path,
2525 ) -> tuple[object, ...]:
2526 """Capture root inheritance, relevant CSS, and the referenced defs closure."""
2527 if not elements:
2528 return ()
2529 root_attrs = tuple(sorted(
2530 (
2531 name,
2532 _signature_attr_value(
2533 name,
2534 value,
2535 svg_path=svg_path,
2536 asset_identity=True,
2537 ),
2538 )
2539 for name, value in root.attrib.items()
2540 if (
2541 name.rsplit("}", 1)[-1] not in _STRUCTURE_ATTRS
2542 and not name.rsplit("}", 1)[-1].startswith("data-")
2543 )
2544 ))
2545 css_rules = _scope_css_signature(root, elements, svg_path)
2546 references: set[str] = set()
2547 for element in elements:
2548 references.update(_svg_reference_ids(element))
2549 for _selector, declarations in css_rules:
2550 references.update(
2551 match.group(2)[1:]
2552 for match in _CSS_URL_RE.finditer(declarations)
2553 if match.group(2).startswith("#")
2554 )
2555
2556 definitions_by_id: dict[str, ET.Element] = {}
2557 for definitions in root.iter():
2558 if _local_tag(definitions) != "defs":
2559 continue
2560 for definition in definitions.iter():
2561 definition_id = (definition.get("id") or "").strip()
2562 if definition_id:
2563 definitions_by_id[definition_id] = definition
2564
2565 pending = list(references)
2566 resolved: set[str] = set()
2567 while pending:
2568 reference = pending.pop()
2569 if reference in resolved:
2570 continue
2571 resolved.add(reference)
2572 definition = definitions_by_id.get(reference)
2573 if definition is not None:
2574 pending.extend(_svg_reference_ids(definition) - resolved)
2575 definition_signatures = tuple(
2576 (
2577 reference,
2578 _element_tree_signature(
2579 definitions_by_id[reference],
2580 include_skin=True,
2581 include_text=True,
2582 svg_path=svg_path,
2583 asset_identity=True,
2584 ) if reference in definitions_by_id else ("missing", reference),
2585 )
2586 for reference in sorted(resolved)
2587 )
2588 return root_attrs, css_rules, definition_signatures
2589
2590
2591 def _structure_subtree_signature(
2592 svg_path: Path,
2593 elements: tuple[TemplateElementSpec, ...],
2594 *,
2595 include_skin: bool = False,
2596 include_text: bool = True,
2597 asset_identity: bool = False,
2598 ) -> tuple[tuple[str, tuple[object, ...]], ...]:
2599 """Read structural or literal-visual signatures for direct SVG children."""
2600 try:
2601 root = _parse_svg_root(svg_path)
2602 materialize_inline_geometry_properties(root)
2603 except (OSError, ET.ParseError, NativePayloadError, GeometryStyleError) as exc:
2604 raise TemplateStructureError(
2605 f"{svg_path.name}: unable to compare template prototype structure: {exc}"
2606 ) from exc
2607 direct_by_id = {
2608 (child.get("id") or "").strip(): child
2609 for child in root
2610 if (child.get("id") or "").strip()
2611 }
2612 signatures: list[tuple[str, tuple[object, ...]]] = []
2613 for item in elements:
2614 child = direct_by_id.get(item.element_id)
2615 if child is None:
2616 raise TemplateStructureError(
2617 f"{svg_path.name}: structure element {item.element_id!r} is no "
2618 "longer a direct SVG child"
2619 )
2620 signatures.append((
2621 item.element_id,
2622 _element_tree_signature(
2623 child,
2624 include_skin=include_skin,
2625 include_text=include_text,
2626 svg_path=svg_path,
2627 asset_identity=asset_identity,
2628 ),
2629 ))
2630 if include_skin:
2631 selected = tuple(
2632 direct_by_id[item.element_id]
2633 for item in elements
2634 if item.element_id in direct_by_id
2635 )
2636 signatures.append((
2637 "__visual_resources__",
2638 _scope_visual_resources_signature(root, selected, svg_path),
2639 ))
2640 return tuple(signatures)
2641
2642
2643 def _mirror_ordinary_slide_ids(spec: TemplateSlideSpec) -> set[str]:
2644 """Return stable ids that are ordinary Slide content in one mirror page."""
2645 try:
2646 root = _parse_svg_root(spec.svg_path)
2647 except (OSError, ET.ParseError, NativePayloadError) as exc:
2648 raise TemplateStructureError(
2649 f"{spec.svg_path.name}: unable to inspect mirror page ownership: {exc}"
2650 ) from exc
2651 inherited_ids = {
2652 item.element_id
2653 for item in (
2654 *spec.master_elements,
2655 *spec.layout_elements,
2656 *spec.placeholders,
2657 )
2658 }
2659 return {
2660 element_id
2661 for child in root
2662 if _local_tag(child) not in _NON_VISUAL_TAGS
2663 if (element_id := (child.get("id") or "").strip())
2664 if element_id not in inherited_ids
2665 }
2666
2667
2668 def _mirror_slide_local_signature(
2669 spec: TemplateSlideSpec,
2670 protected_ids: set[str],
2671 ) -> tuple[tuple[object, ...], tuple[object, ...]]:
2672 """Capture literal mirror visuals that are Slide-local on either page.
2673
2674 Visible text values may change, but their element topology, attributes,
2675 grouping, paint, geometry, and referenced asset bytes remain literal.
2676 Structure metadata may change when adaptive template authoring assigns an
2677 evolved Layout identity to the same stable SVG id.
2678 """
2679 try:
2680 root = _parse_svg_root(spec.svg_path)
2681 materialize_inline_geometry_properties(root)
2682 except (OSError, ET.ParseError, NativePayloadError, GeometryStyleError) as exc:
2683 raise TemplateStructureError(
2684 f"{spec.svg_path.name}: unable to compare mirror page visuals: {exc}"
2685 ) from exc
2686 slide_elements: list[ET.Element] = []
2687 slide_visuals: list[tuple[object, ...]] = []
2688 for child in root:
2689 tag = _local_tag(child)
2690 if tag in _NON_VISUAL_TAGS:
2691 continue
2692 element_id = (child.get("id") or "").strip()
2693 if element_id and element_id not in protected_ids:
2694 continue
2695 slide_elements.append(child)
2696 slide_visuals.append(
2697 _element_tree_signature(
2698 child,
2699 include_skin=True,
2700 include_text=False,
2701 svg_path=spec.svg_path,
2702 asset_identity=True,
2703 ignore_structure_attrs=True,
2704 )
2705 )
2706 resources = _scope_visual_resources_signature(
2707 root,
2708 tuple(slide_elements),
2709 spec.svg_path,
2710 )
2711 return resources, tuple(slide_visuals)
2712
2713
2714 def _prototype_placeholder_contract(
2715 spec: TemplateSlideSpec,
2716 ) -> tuple[tuple[object, ...], ...]:
2717 """Return the strict template placeholder contract without slide content."""
2718 return tuple(item.contract_signature() for item in spec.placeholders)
2719
2720
2721 def _layout_contract_difference(
2722 actual: tuple[TemplateElementSpec, ...],
2723 expected: tuple[TemplateElementSpec, ...],
2724 ) -> str:
2725 """Describe the smallest actionable difference in a Layout atom roster."""
2726 actual_ids = tuple(item.element_id for item in actual)
2727 expected_ids = tuple(item.element_id for item in expected)
2728 actual_id_set = set(actual_ids)
2729 expected_id_set = set(expected_ids)
2730 missing = tuple(item for item in expected_ids if item not in actual_id_set)
2731 unexpected = tuple(item for item in actual_ids if item not in expected_id_set)
2732 details: list[str] = []
2733 if missing:
2734 details.append(
2735 "missing generated Layout element id(s): "
2736 + ", ".join(repr(item) for item in missing)
2737 )
2738 if unexpected:
2739 details.append(
2740 "unexpected generated Layout element id(s): "
2741 + ", ".join(repr(item) for item in unexpected)
2742 )
2743 if not details and actual_ids != expected_ids:
2744 details.append("generated Layout element order differs")
2745 if not details:
2746 details.append(
2747 "shared Layout element metadata, geometry, topology, or content differs"
2748 )
2749 return "; ".join(details)
2750
2751
2752 def _mirror_comparable_attributes(
2753 element: ET.Element,
2754 svg_path: Path,
2755 *,
2756 ignore_structure_attrs: bool,
2757 ) -> dict[str, str]:
2758 """Return literal mirror attributes using the validator's normalization."""
2759 return {
2760 name: _signature_attr_value(
2761 name,
2762 value,
2763 svg_path=svg_path,
2764 asset_identity=True,
2765 )
2766 for name, value in element.attrib.items()
2767 if not (
2768 ignore_structure_attrs
2769 and name.rsplit("}", 1)[-1] in _STRUCTURE_ATTRS
2770 )
2771 }
2772
2773
2774 def _mirror_node_label(element: ET.Element, index: int) -> str:
2775 """Return a compact stable-enough path segment for one SVG node."""
2776 tag = _local_tag(element)
2777 element_id = (element.get("id") or "").strip()
2778 return f"{tag}#{element_id}" if element_id else f"{tag}[{index}]"
2779
2780
2781 def _mirror_element_difference(
2782 expected: ET.Element,
2783 actual: ET.Element,
2784 *,
2785 expected_svg: Path,
2786 actual_svg: Path,
2787 path: str,
2788 ignore_structure_attrs: bool,
2789 ) -> str | None:
2790 """Describe the first literal mirror subtree difference."""
2791 expected_tag = _local_tag(expected)
2792 actual_tag = _local_tag(actual)
2793 if expected_tag != actual_tag:
2794 return f"{path}: expected <{expected_tag}>, found <{actual_tag}>"
2795
2796 expected_attrs = _mirror_comparable_attributes(
2797 expected,
2798 expected_svg,
2799 ignore_structure_attrs=ignore_structure_attrs,
2800 )
2801 actual_attrs = _mirror_comparable_attributes(
2802 actual,
2803 actual_svg,
2804 ignore_structure_attrs=ignore_structure_attrs,
2805 )
2806 if expected_attrs != actual_attrs:
2807 for name in sorted(set(expected_attrs) | set(actual_attrs)):
2808 expected_value = expected_attrs.get(name)
2809 actual_value = actual_attrs.get(name)
2810 if expected_value != actual_value:
2811 return (
2812 f"{path}: attribute {name.rsplit('}', 1)[-1]!r} expected "
2813 f"{expected_value!r}, found {actual_value!r}"
2814 )
2815
2816 expected_children = list(expected)
2817 actual_children = list(actual)
2818 if len(expected_children) != len(actual_children):
2819 expected_tspans = sum(
2820 _local_tag(child) == "tspan" for child in expected_children
2821 )
2822 actual_tspans = sum(
2823 _local_tag(child) == "tspan" for child in actual_children
2824 )
2825 if expected_tspans != actual_tspans:
2826 return (
2827 f"{path}: expected {expected_tspans} direct <tspan> child(ren), "
2828 f"found {actual_tspans}; mirror text node count/order must stay "
2829 "unchanged"
2830 )
2831 return (
2832 f"{path}: expected {len(expected_children)} child node(s), found "
2833 f"{len(actual_children)}"
2834 )
2835
2836 for index, (expected_child, actual_child) in enumerate(
2837 zip(expected_children, actual_children),
2838 start=1,
2839 ):
2840 child_path = f"{path}/{_mirror_node_label(expected_child, index)}"
2841 difference = _mirror_element_difference(
2842 expected_child,
2843 actual_child,
2844 expected_svg=expected_svg,
2845 actual_svg=actual_svg,
2846 path=child_path,
2847 ignore_structure_attrs=ignore_structure_attrs,
2848 )
2849 if difference:
2850 return difference
2851 return None
2852
2853
2854 def _mirror_slide_local_difference(
2855 expected_spec: TemplateSlideSpec,
2856 actual_spec: TemplateSlideSpec,
2857 protected_ids: set[str],
2858 ) -> str | None:
2859 """Describe the first Slide-local mirror difference."""
2860 expected_root = _parse_svg_root(expected_spec.svg_path)
2861 actual_root = _parse_svg_root(actual_spec.svg_path)
2862 materialize_inline_geometry_properties(expected_root)
2863 materialize_inline_geometry_properties(actual_root)
2864
2865 def selected(root: ET.Element) -> list[ET.Element]:
2866 output: list[ET.Element] = []
2867 for child in root:
2868 if _local_tag(child) in _NON_VISUAL_TAGS:
2869 continue
2870 element_id = (child.get("id") or "").strip()
2871 if element_id and element_id not in protected_ids:
2872 continue
2873 output.append(child)
2874 return output
2875
2876 expected_children = selected(expected_root)
2877 actual_children = selected(actual_root)
2878 if len(expected_children) != len(actual_children):
2879 return (
2880 f"svg: expected {len(expected_children)} Slide-local top-level "
2881 f"element(s), found {len(actual_children)}"
2882 )
2883 for index, (expected, actual) in enumerate(
2884 zip(expected_children, actual_children),
2885 start=1,
2886 ):
2887 difference = _mirror_element_difference(
2888 expected,
2889 actual,
2890 expected_svg=expected_spec.svg_path,
2891 actual_svg=actual_spec.svg_path,
2892 path=f"svg/{_mirror_node_label(expected, index)}",
2893 ignore_structure_attrs=True,
2894 )
2895 if difference:
2896 return difference
2897 if _scope_visual_resources_signature(
2898 expected_root,
2899 tuple(expected_children),
2900 expected_spec.svg_path,
2901 ) != _scope_visual_resources_signature(
2902 actual_root,
2903 tuple(actual_children),
2904 actual_spec.svg_path,
2905 ):
2906 return "svg: referenced defs, CSS, root styling, or asset identity differs"
2907 return None
2908
2909
2910 def _mirror_structure_difference(
2911 expected_spec: TemplateSlideSpec,
2912 actual_spec: TemplateSlideSpec,
2913 elements: tuple[TemplateElementSpec, ...],
2914 ) -> str | None:
2915 """Describe the first mirror difference in named structural elements."""
2916 expected_root = _parse_svg_root(expected_spec.svg_path)
2917 actual_root = _parse_svg_root(actual_spec.svg_path)
2918 materialize_inline_geometry_properties(expected_root)
2919 materialize_inline_geometry_properties(actual_root)
2920 expected_by_id = {
2921 (child.get("id") or "").strip(): child
2922 for child in expected_root
2923 if (child.get("id") or "").strip()
2924 }
2925 actual_by_id = {
2926 (child.get("id") or "").strip(): child
2927 for child in actual_root
2928 if (child.get("id") or "").strip()
2929 }
2930 for item in elements:
2931 expected = expected_by_id.get(item.element_id)
2932 actual = actual_by_id.get(item.element_id)
2933 if expected is None or actual is None:
2934 return (
2935 f"svg/{item.element_id}: expected direct structural element is "
2936 "missing from one side"
2937 )
2938 difference = _mirror_element_difference(
2939 expected,
2940 actual,
2941 expected_svg=expected_spec.svg_path,
2942 actual_svg=actual_spec.svg_path,
2943 path=f"svg/{_mirror_node_label(expected, 1)}",
2944 ignore_structure_attrs=False,
2945 )
2946 if difference:
2947 return difference
2948 return None
2949
2950
2951 def template_prototype_errors(
2952 specs: list[TemplateSlideSpec],
2953 structure_lock: PptxStructureLock,
2954 *,
2955 require_complete_roster: bool = True,
2956 ) -> list[str]:
2957 """Compare structured template pages with their selected SVG prototypes."""
2958 if structure_lock.mode != "structured" or not structure_lock.prototypes:
2959 return []
2960 errors: list[str] = []
2961 prototypes = {
2962 reference.slide_num: reference
2963 for reference in structure_lock.prototypes
2964 }
2965 adherence = structure_lock.template_adherence or "strict"
2966 actual_slides = {spec.slide_num for spec in specs}
2967 prototype_slides = set(prototypes)
2968 missing_prototypes = sorted(actual_slides - prototype_slides)
2969 extra_prototypes = sorted(prototype_slides - actual_slides)
2970 if missing_prototypes:
2971 errors.append(
2972 "spec_lock.md page_layouts is missing generated page(s): "
2973 + ", ".join(f"P{slide_num:02d}" for slide_num in missing_prototypes)
2974 )
2975 if require_complete_roster and extra_prototypes:
2976 errors.append(
2977 "spec_lock.md page_layouts references absent page(s): "
2978 + ", ".join(f"P{slide_num:02d}" for slide_num in extra_prototypes)
2979 )
2980 for spec in specs:
2981 reference = prototypes.get(spec.slide_num)
2982 if reference is None:
2983 errors.append(
2984 f"{spec.svg_path.name}: spec_lock.md page_layouts is missing "
2985 f"prototype P{spec.slide_num:02d}"
2986 )
2987 continue
2988 try:
2989 prototype = parse_template_slide(reference.svg_path, spec.slide_num)
2990 except TemplateStructureError as exc:
2991 errors.append(str(exc))
2992 continue
2993
2994 try:
2995 literal_visual = (
2996 structure_lock.template_reuse_scope == "mirror"
2997 if structure_lock.template_reuse_scope is not None
2998 else reference.replication_mode == "mirror"
2999 )
3000 expected_master_structure = _structure_subtree_signature(
3001 prototype.svg_path,
3002 prototype.master_elements,
3003 include_skin=literal_visual,
3004 asset_identity=literal_visual,
3005 )
3006 actual_master_structure = _structure_subtree_signature(
3007 spec.svg_path,
3008 spec.master_elements,
3009 include_skin=literal_visual,
3010 asset_identity=literal_visual,
3011 )
3012 except TemplateStructureError as exc:
3013 errors.append(str(exc))
3014 continue
3015 if (
3016 spec.master_key != prototype.master_key
3017 or spec.master_name != prototype.master_name
3018 or tuple(item.contract_signature() for item in spec.master_elements)
3019 != tuple(
3020 item.contract_signature() for item in prototype.master_elements
3021 )
3022 or actual_master_structure != expected_master_structure
3023 ):
3024 master_difference = (
3025 _mirror_structure_difference(
3026 prototype,
3027 spec,
3028 prototype.master_elements,
3029 )
3030 if literal_visual
3031 else None
3032 )
3033 errors.append(
3034 f"{spec.svg_path.name}: template Master structure differs "
3035 f"from prototype {reference.svg_path.name}; strict and adaptive "
3036 "routes must retain its ids, topology, geometry, and content"
3037 + (" including mirror visual styling" if literal_visual else "")
3038 + (
3039 f"; first difference: {master_difference}"
3040 if master_difference else ""
3041 )
3042 )
3043
3044 if literal_visual:
3045 try:
3046 protected_slide_ids = (
3047 _mirror_ordinary_slide_ids(prototype)
3048 | _mirror_ordinary_slide_ids(spec)
3049 )
3050 expected_slide_visual = _mirror_slide_local_signature(
3051 prototype,
3052 protected_slide_ids,
3053 )
3054 actual_slide_visual = _mirror_slide_local_signature(
3055 spec,
3056 protected_slide_ids,
3057 )
3058 except TemplateStructureError as exc:
3059 errors.append(str(exc))
3060 continue
3061 if actual_slide_visual != expected_slide_visual:
3062 difference = _mirror_slide_local_difference(
3063 prototype,
3064 spec,
3065 protected_slide_ids,
3066 )
3067 errors.append(
3068 f"{spec.svg_path.name}: mirror Slide-local non-text visuals "
3069 f"differ from prototype {reference.svg_path.name}; preserve "
3070 "grouping, geometry, paint, effects, and referenced asset "
3071 "identity, changing only visible text content"
3072 + (f"; first difference: {difference}" if difference else "")
3073 )
3074
3075 try:
3076 expected_layout_structure = _structure_subtree_signature(
3077 prototype.svg_path,
3078 prototype.layout_elements,
3079 include_skin=literal_visual,
3080 asset_identity=literal_visual,
3081 )
3082 actual_layout_structure = _structure_subtree_signature(
3083 spec.svg_path,
3084 spec.layout_elements,
3085 include_skin=literal_visual,
3086 asset_identity=literal_visual,
3087 )
3088 if literal_visual:
3089 expected_placeholder_visual = _structure_subtree_signature(
3090 prototype.svg_path,
3091 prototype.placeholders,
3092 include_skin=True,
3093 include_text=False,
3094 asset_identity=True,
3095 )
3096 actual_placeholder_visual = _structure_subtree_signature(
3097 spec.svg_path,
3098 spec.placeholders,
3099 include_skin=True,
3100 include_text=False,
3101 asset_identity=True,
3102 )
3103 else:
3104 expected_placeholder_visual = ()
3105 actual_placeholder_visual = ()
3106 except TemplateStructureError as exc:
3107 errors.append(str(exc))
3108 continue
3109
3110 placeholder_contract_same = (
3111 _prototype_placeholder_contract(spec)
3112 == _prototype_placeholder_contract(prototype)
3113 )
3114 layout_contract_same = (
3115 spec.layout_show_master_shapes
3116 == prototype.layout_show_master_shapes
3117 and tuple(item.contract_signature() for item in spec.layout_elements)
3118 == tuple(
3119 item.contract_signature() for item in prototype.layout_elements
3120 )
3121 and actual_layout_structure == expected_layout_structure
3122 )
3123 placeholder_visual_same = (
3124 actual_placeholder_visual == expected_placeholder_visual
3125 )
3126 reusable_contract_same = (
3127 placeholder_contract_same
3128 and layout_contract_same
3129 and placeholder_visual_same
3130 )
3131
3132 reuses_prototype_key = spec.layout_key == prototype.layout_key
3133 if adherence == "adaptive" and not reuses_prototype_key:
3134 if spec.layout_name == prototype.layout_name:
3135 errors.append(
3136 f"{spec.svg_path.name}: adaptive template authoring created "
3137 f"new layout key {spec.layout_key!r} but reused prototype picker "
3138 f"name {prototype.layout_name!r}; assign a new key and name to "
3139 "the evolved Layout contract"
3140 )
3141 if reusable_contract_same:
3142 errors.append(
3143 f"{spec.svg_path.name}: adaptive template authoring changed "
3144 "only the Layout key/name while the reusable static, "
3145 "placeholder, and default-bounds contract is unchanged; "
3146 f"reuse prototype identity {prototype.layout_key!r} / "
3147 f"{prototype.layout_name!r}"
3148 )
3149 continue
3150
3151 missing_bounds = [
3152 item.element_id
3153 for item in prototype.placeholders
3154 if item.placeholder_bounds is None
3155 ]
3156 if missing_bounds:
3157 if adherence == "strict":
3158 errors.append(
3159 f"{reference.svg_path.name}: deferred strict template "
3160 "authoring requires explicit data-pptx-bounds "
3161 "on every prototype placeholder; missing: "
3162 + ", ".join(missing_bounds)
3163 )
3164 else:
3165 errors.append(
3166 f"{spec.svg_path.name}: adaptive output reused prototype layout "
3167 f"key {prototype.layout_key!r}, but that prototype lacks explicit "
3168 "placeholder bounds; assign a new key and name to the evolved "
3169 "Layout contract"
3170 )
3171 continue
3172 if spec.layout_key != prototype.layout_key:
3173 errors.append(
3174 f"{spec.svg_path.name}: strict template use must keep "
3175 f"prototype layout key {prototype.layout_key!r}, found "
3176 f"{spec.layout_key!r}"
3177 )
3178 if spec.layout_name != prototype.layout_name:
3179 if adherence == "strict":
3180 errors.append(
3181 f"{spec.svg_path.name}: strict template use must keep "
3182 f"prototype layout name {prototype.layout_name!r}, found "
3183 f"{spec.layout_name!r}"
3184 )
3185 else:
3186 errors.append(
3187 f"{spec.svg_path.name}: adaptive output reused prototype layout "
3188 f"key {prototype.layout_key!r} but changed its picker name from "
3189 f"{prototype.layout_name!r} to {spec.layout_name!r}; assign a "
3190 "new key and name to the evolved Layout contract"
3191 )
3192 if (
3193 spec.slide_show_inherited_shapes
3194 != prototype.slide_show_inherited_shapes
3195 ):
3196 errors.append(
3197 f"{spec.svg_path.name}: inherited-shape visibility differs from "
3198 f"prototype {reference.svg_path.name}; keep root "
3199 "data-pptx-show-inherited-shapes unchanged"
3200 )
3201 if not placeholder_contract_same:
3202 if adherence == "strict":
3203 errors.append(
3204 f"{spec.svg_path.name}: strict placeholder id/type/index/default-"
3205 f"bounds contract differs from prototype "
3206 f"{reference.svg_path.name}"
3207 )
3208 else:
3209 errors.append(
3210 f"{spec.svg_path.name}: adaptive output reused prototype layout "
3211 f"key {prototype.layout_key!r} but changed its placeholder "
3212 "contract; assign a new key and name"
3213 )
3214 if literal_visual and not placeholder_visual_same:
3215 difference = _mirror_structure_difference(
3216 prototype,
3217 spec,
3218 prototype.placeholders,
3219 )
3220 errors.append(
3221 f"{spec.svg_path.name}: mirror placeholder geometry or visual "
3222 f"styling differs from prototype {reference.svg_path.name}; "
3223 "only visible text content may change under the reused Layout"
3224 + (f"; first difference: {difference}" if difference else "")
3225 )
3226 if not layout_contract_same:
3227 qualifier = "mirror visual/structural" if literal_visual else "structural"
3228 difference = _layout_contract_difference(
3229 spec.layout_elements,
3230 prototype.layout_elements,
3231 )
3232 if literal_visual:
3233 literal_difference = _mirror_structure_difference(
3234 prototype,
3235 spec,
3236 prototype.layout_elements,
3237 )
3238 if literal_difference:
3239 difference += f"; first difference: {literal_difference}"
3240 if adherence == "strict":
3241 errors.append(
3242 f"{spec.svg_path.name}: strict Layout {qualifier} contract "
3243 f"differs from prototype {reference.svg_path.name}; {difference}"
3244 )
3245 else:
3246 errors.append(
3247 f"{spec.svg_path.name}: adaptive output reused prototype layout "
3248 f"key {prototype.layout_key!r} but changed its {qualifier} "
3249 f"contract; {difference}; assign a new key and name"
3250 )
3251 return errors
3252
3253
3254 _PRESERVE_PLACEHOLDER_TYPE_ORDER = {
3255 "title": ("title", "ctrTitle"),
3256 "subtitle": ("subTitle", "body", "obj"),
3257 "body": ("body", "obj", "subTitle"),
3258 "picture": ("pic", "obj"),
3259 "chart": ("chart", "obj"),
3260 "table": ("tbl", "obj"),
3261 "object": ("obj",),
3262 "media": ("media", "obj", "pic"),
3263 "date": ("dt",),
3264 "footer": ("ftr",),
3265 "slide-number": ("sldNum",),
3266 }
3267
3268
3269 def match_native_placeholders(
3270 spec: TemplateSlideSpec,
3271 layout: NativeLayoutSpec,
3272 ) -> tuple[tuple[TemplateElementSpec, NativePlaceholderSpec], ...]:
3273 """Match slide placeholder markers to source layout placeholder identities."""
3274 available = list(layout.placeholders)
3275 matches: list[tuple[TemplateElementSpec, NativePlaceholderSpec]] = []
3276 for item in spec.placeholders:
3277 allowed_types = _PRESERVE_PLACEHOLDER_TYPE_ORDER.get(
3278 item.placeholder or "",
3279 (),
3280 )
3281 candidate_index = None
3282 for placeholder_type in allowed_types:
3283 for index, candidate in enumerate(available):
3284 if candidate.placeholder_type != placeholder_type:
3285 continue
3286 if (
3287 item.placeholder_idx is not None
3288 and candidate.effective_idx != item.placeholder_idx
3289 ):
3290 continue
3291 candidate_index = index
3292 break
3293 if candidate_index is not None:
3294 break
3295 if candidate_index is None:
3296 idx_note = (
3297 f" idx={item.placeholder_idx}"
3298 if item.placeholder_idx is not None
3299 else ""
3300 )
3301 raise TemplateStructureError(
3302 f"{spec.svg_path.name}: placeholder {item.element_id!r} "
3303 f"({item.placeholder}{idx_note}) has no compatible source placeholder "
3304 f"in layout {layout.key!r}"
3305 )
3306 matches.append((item, available.pop(candidate_index)))
3307 return tuple(matches)
3308
3309
3310 def native_structure_lock_errors(
3311 specs: list[TemplateSlideSpec],
3312 structure_lock: PptxStructureLock,
3313 contract: NativeStructureContract,
3314 ) -> list[str]:
3315 """Return preserve-mode mismatches against the imported source contract."""
3316 if structure_lock.mode != "preserve":
3317 return []
3318 errors: list[str] = []
3319 references = {item.slide_num: item for item in structure_lock.layouts}
3320 contract_layouts = {layout.key: layout for layout in contract.layouts}
3321
3322 for reference in structure_lock.layouts:
3323 layout = contract_layouts.get(reference.layout_key)
3324 if layout is None:
3325 errors.append(
3326 f"spec_lock.md P{reference.slide_num:02d} references unknown source "
3327 f"layout key {reference.layout_key!r}"
3328 )
3329 continue
3330 if reference.layout_name and reference.layout_name != layout.name:
3331 errors.append(
3332 f"spec_lock.md P{reference.slide_num:02d} layout name "
3333 f"{reference.layout_name!r} does not match source name {layout.name!r}"
3334 )
3335
3336 master_contracts: dict[str, tuple[tuple[object, ...], ...]] = {}
3337 layout_contracts: dict[str, tuple[tuple[object, ...], ...]] = {}
3338 for spec in specs:
3339 reference = references.get(spec.slide_num)
3340 if reference is None:
3341 continue
3342 layout = contract_layouts.get(reference.layout_key)
3343 if layout is None:
3344 continue
3345 master_contract = tuple(
3346 item.contract_signature() for item in spec.master_elements
3347 )
3348 expected_master = master_contracts.setdefault(
3349 layout.master_key,
3350 master_contract,
3351 )
3352 if master_contract != expected_master:
3353 errors.append(
3354 f"{spec.svg_path.name}: preview master layer differs from another "
3355 f"page using source master {layout.master_key!r}"
3356 )
3357 expected_layout = layout_contracts.setdefault(
3358 layout.key,
3359 spec.layout_contract,
3360 )
3361 if spec.layout_contract != expected_layout:
3362 errors.append(
3363 f"{spec.svg_path.name}: preview layout/placeholder contract differs "
3364 f"from another page using source layout {layout.key!r}"
3365 )
3366 try:
3367 match_native_placeholders(spec, layout)
3368 except TemplateStructureError as exc:
3369 errors.append(str(exc))
3370 return errors
3371
3372
3373 def _placement_lint_errors(svg_path: Path) -> list[str]:
3374 """Enumerate every placement/paint-order violation in one pass.
3375
3376 ``parse_template_slide`` fails fast on the first error, which discloses
3377 violations one whole fix-cycle at a time. The quality checker runs this
3378 pre-lint first so a single run reports every offender of the two
3379 highest-frequency classes: structure metadata below the root, and
3380 template paint-order violations.
3381 """
3382 try:
3383 root = _parse_svg_root(svg_path)
3384 except (OSError, ET.ParseError, NativePayloadError):
3385 return []
3386 if _local_tag(root) != "svg":
3387 return []
3388 errors: list[str] = []
3389 direct_children = set(root)
3390 allowed_carriers = {
3391 carrier
3392 for slot in root
3393 if (slot.get("data-pptx-placeholder") or "").strip()
3394 for carrier in slot
3395 if carrier.get("data-pptx-carrier") is not None
3396 }
3397 for elem in root.iter():
3398 if elem is root or elem in direct_children:
3399 continue
3400 attrs = _structure_attrs(elem)
3401 if elem in allowed_carriers:
3402 attrs = [
3403 attr for attr in attrs
3404 if attr != "data-pptx-carrier"
3405 ]
3406 if attrs:
3407 element_id = elem.get("id") or _local_tag(elem) or "<unnamed>"
3408 errors.append(
3409 f"{svg_path.name}: {element_id} uses template metadata below the SVG "
3410 "root; only a direct slot child may declare its carrier marker"
3411 )
3412 try:
3413 canvas = _svg_canvas(root)
3414 except CanvasContractError:
3415 # Root-canvas validation is owned by parse_template_slide and the
3416 # page Checker; placement lint should not duplicate that diagnosis.
3417 return errors
3418 last_order_rank = -1
3419 for elem in root:
3420 tag = _local_tag(elem)
3421 if tag in _NON_VISUAL_TAGS:
3422 continue
3423 layer = (elem.get("data-pptx-layer") or "").strip().lower() or None
3424 if layer not in _LAYERS:
3425 layer = None
3426 is_background = _is_full_canvas_solid_rect(elem, canvas)
3427 effective_layer = layer or ("slide" if is_background else None)
3428 if is_background and effective_layer is not None:
3429 order_rank = {"master": 0, "layout": 1, "slide": 2}[effective_layer]
3430 elif effective_layer == "master":
3431 order_rank = 3
3432 elif effective_layer == "layout":
3433 order_rank = 4
3434 else:
3435 order_rank = 5
3436 if order_rank < last_order_rank:
3437 errors.append(
3438 f"{svg_path.name}: {elem.get('id') or tag} violates template paint "
3439 "order; use Master background, Layout background, Slide background, "
3440 "Master shapes, Layout shapes, then Slide content/placeholders"
3441 )
3442 continue
3443 last_order_rank = order_rank
3444 return errors
3445
3446
3447 def validate_template_svg(svg_path: Path) -> list[str]:
3448 """Return per-file template metadata errors for quality-check integration."""
3449 errors = _placement_lint_errors(svg_path)
3450 try:
3451 parse_template_slide(svg_path, 1)
3452 except TemplateStructureError as exc:
3453 message = str(exc)
3454 if message not in errors:
3455 errors.append(message)
3456 return errors
3457
3457 lines PYTHON