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