返回 ppt-master
mirror_template_materialize.py
根目录 / skills / ppt-master / scripts / mirror_template_materialize.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Mirror Template Materializer
4
5 Materialize a deterministic structured SVG template workspace from one Type A
6 PPTX import workspace. The editable authoring IR is the only authoring input;
7 lossless SVG files are consulted solely to restore unchanged supported source
8 objects. Final templates and imported vectors contain no IR-only source refs.
9
10 Usage:
11 python3 scripts/mirror_template_materialize.py \
12 <import_workspace> <template_workspace>
13
14 Dependencies:
15 None (standard library and sibling PPT Master modules only).
16 """
17
18 from __future__ import annotations
19
20 import argparse
21 import base64
22 import copy
23 import hashlib
24 import json
25 import math
26 import os
27 import re
28 import sys
29 import tempfile
30 from collections import Counter
31 from dataclasses import dataclass
32 from pathlib import Path
33 from typing import Any, Iterable
34 from urllib.parse import unquote, urlsplit, urlunsplit
35 from xml.etree import ElementTree as ET
36
37 from compact_svg_coordinates import compact_svg_tree, format_coordinate
38 from console_encoding import configure_utf8_stdio
39 from native_payloads import (
40 PAYLOAD_STORE_RELATIVE_PATH,
41 NativePayloadError,
42 NativePayloadStats,
43 build_native_attribute_records,
44 collect_native_attribute_record_keys,
45 externalize_native_attribute_records,
46 externalize_native_payloads,
47 hydrate_native_payload_refs,
48 serialize_native_payload_store,
49 )
50 from pptx_shapes import svg_preset_preview_fingerprint
51 from svg_authoring_view import (
52 AUTHORING_MANIFEST_NAME,
53 AUTHORING_SCHEMA,
54 SOURCE_REF_ATTRIBUTE,
55 semantic_subtree_sha256,
56 )
57 from svg_finalize.flatten_tspan import flatten_text_with_tspans
58 from svg_to_pptx.pptx_package.template_structure import (
59 TemplateStructureError,
60 parse_template_slides,
61 )
62 from template_text_slots import (
63 analyze_template_text_slots,
64 text_slot_integrity_sha256,
65 )
66
67 configure_utf8_stdio()
68
69 SVG_NS = "http://www.w3.org/2000/svg"
70 XLINK_NS = "http://www.w3.org/1999/xlink"
71 XML_NS = "http://www.w3.org/XML/1998/namespace"
72 NATIVE_STRUCTURE_SCHEMA = "ppt-master.native-structure.v1"
73 VECTOR_INVENTORY_SCHEMA = "vector_asset_inventory.v1"
74 TEMPLATE_EXECUTION_MANIFEST_NAME = "template_execution_manifest.json"
75 TEMPLATE_EXECUTION_MANIFEST_SCHEMA = "ppt-master.template-execution-manifest.v1"
76 TEMPLATE_TEXT_SLOTS_DIR = "template_execution"
77 TEMPLATE_TEXT_SLOTS_SCHEMA = "ppt-master.template-text-slots.v2-min"
78 IMPORTED_ICON_NAMESPACE = "imported"
79 TRANSPARENT_PIXEL_DATA_URI = (
80 "data:image/png;base64,"
81 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUA"
82 "AXpeqz8AAAAASUVORK5CYII="
83 )
84
85 ET.register_namespace("", SVG_NS)
86 ET.register_namespace("xlink", XLINK_NS)
87
88 _NON_VISUAL_TAGS = frozenset({"defs", "desc", "metadata", "style", "title"})
89 _BITMAP_EXTENSIONS = frozenset({
90 ".avif",
91 ".bmp",
92 ".gif",
93 ".jpeg",
94 ".jpg",
95 ".png",
96 ".tif",
97 ".tiff",
98 ".webp",
99 })
100 _INHERITED_PRESENTATION_ATTRIBUTES = frozenset({
101 "color",
102 "fill",
103 "fill-opacity",
104 "font-family",
105 "font-size",
106 "font-style",
107 "font-weight",
108 "letter-spacing",
109 "paint-order",
110 "shape-rendering",
111 "stroke",
112 "stroke-dasharray",
113 "stroke-dashoffset",
114 "stroke-linecap",
115 "stroke-linejoin",
116 "stroke-miterlimit",
117 "stroke-opacity",
118 "stroke-width",
119 "text-anchor",
120 "text-decoration",
121 "word-spacing",
122 })
123 _AGGREGATE_GROUP_ATTRIBUTES = frozenset({
124 "clip-path",
125 "filter",
126 "mask",
127 "mix-blend-mode",
128 "opacity",
129 })
130 _URL_REFERENCE_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"\s]+)\1\s*\)")
131 _CSS_ID_RE = re.compile(r"#([A-Za-z_][A-Za-z0-9_.:-]*)")
132 _SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9_.-]+")
133 _TRANSFORM_NUMBER = r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?"
134 _AXIS_REFLECTION_RE = re.compile(
135 rf"^\s*translate\(\s*({_TRANSFORM_NUMBER})[\s,]+"
136 rf"({_TRANSFORM_NUMBER})\s*\)\s*"
137 rf"scale\(\s*({_TRANSFORM_NUMBER})[\s,]+"
138 rf"({_TRANSFORM_NUMBER})\s*\)\s*"
139 rf"translate\(\s*({_TRANSFORM_NUMBER})[\s,]+"
140 rf"({_TRANSFORM_NUMBER})\s*\)\s*$"
141 )
142
143
144 class MirrorMaterializationError(RuntimeError):
145 """Reject incomplete, ambiguous, or unsafe mirror materialization input."""
146
147
148 @dataclass(frozen=True)
149 class SourceRefRecord:
150 source_path: tuple[int, ...]
151 initial_authoring_subtree_sha256: str
152
153
154 @dataclass(frozen=True)
155 class AuthoringDocument:
156 name: str
157 authoring_path: Path
158 source_path: Path
159 source_sha256: str
160 source_refs: dict[str, SourceRefRecord]
161
162
163 @dataclass(frozen=True)
164 class VectorAssetRecord:
165 icon: str
166 asset_path: Path
167 origin_document: str
168 expected_sha256: str
169 source_refs: tuple[str, ...]
170
171
172 @dataclass(frozen=True)
173 class SlotPlan:
174 slot_id: str
175 semantic_role: str
176 placeholder_type: str | None
177 idx: int | None
178 shape_id: str
179 bounds: tuple[float, float, float, float]
180
181
182 @dataclass
183 class RestorationStats:
184 rehydrated_refs: int = 0
185 fallback_refs: int = 0
186 structural_refs: int = 0
187 detached_connector_endpoints: int = 0
188 upright_text_compensations: int = 0
189
190 def merge(self, other: "RestorationStats") -> None:
191 self.rehydrated_refs += other.rehydrated_refs
192 self.fallback_refs += other.fallback_refs
193 self.structural_refs += other.structural_refs
194 self.detached_connector_endpoints += other.detached_connector_endpoints
195 self.upright_text_compensations += other.upright_text_compensations
196
197 def as_dict(self) -> dict[str, int]:
198 return {
199 "rehydrated_refs": self.rehydrated_refs,
200 "fallback_refs": self.fallback_refs,
201 "structural_refs": self.structural_refs,
202 "detached_connector_endpoints": self.detached_connector_endpoints,
203 "upright_text_compensations": self.upright_text_compensations,
204 }
205
206
207 @dataclass
208 class MaterializedFile:
209 relative_path: Path
210 payload: bytes
211
212
213 def _json_bytes(payload: dict[str, object]) -> bytes:
214 return (
215 json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
216 ).encode("utf-8")
217
218
219 def _compact_json_bytes(payload: dict[str, object]) -> bytes:
220 return (
221 json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
222 ).encode("utf-8")
223
224
225 def _template_execution_manifest_files(
226 materialized_roots: list[tuple[Path, ET.Element]],
227 source_import: dict[str, object] | None,
228 ) -> list[MaterializedFile]:
229 """Serialize one compact roster plus per-prototype text-slot sidecars."""
230 templates: list[dict[str, object]] = []
231 files: list[MaterializedFile] = []
232 for relative_path, root in sorted(
233 materialized_roots,
234 key=lambda item: item[0].as_posix(),
235 ):
236 prototype = relative_path.name
237 try:
238 analyzed_slots = analyze_template_text_slots(root)
239 except ValueError as exc:
240 raise MirrorMaterializationError(
241 f"Cannot project text slots for {prototype}: {exc}"
242 ) from exc
243 text_slots = [slot.model_payload() for slot in analyzed_slots]
244 editable_text_slot_count = sum(slot.editable for slot in analyzed_slots)
245 text_slots_path = (
246 Path("templates")
247 / TEMPLATE_TEXT_SLOTS_DIR
248 / f"{relative_path.stem}.text-slots.json"
249 )
250 files.append(MaterializedFile(
251 text_slots_path,
252 _compact_json_bytes({
253 "schema": TEMPLATE_TEXT_SLOTS_SCHEMA,
254 "prototype": prototype,
255 "text_slot_count": len(text_slots),
256 "tool_integrity_sha256": text_slot_integrity_sha256(analyzed_slots),
257 "text_slots": text_slots,
258 }),
259 ))
260 templates.append({
261 "prototype": prototype,
262 "page_type": relative_path.stem.split("_", 1)[-1],
263 "viewBox": root.get("viewBox"),
264 "master": root.get("data-pptx-master"),
265 "layout": root.get("data-pptx-layout"),
266 "layout_name": root.get("data-pptx-layout-name"),
267 "text_slot_count": len(text_slots),
268 "editable_text_slot_count": editable_text_slot_count,
269 "text_slots_path": text_slots_path.relative_to(
270 Path("templates")
271 ).as_posix(),
272 })
273 payload = {
274 "schema": TEMPLATE_EXECUTION_MANIFEST_SCHEMA,
275 "replication_mode": "mirror",
276 "template_root": ".",
277 "template_count": len(templates),
278 "text_slots_schema": TEMPLATE_TEXT_SLOTS_SCHEMA,
279 "execution_policy": (
280 "This manifest and its text_slots_path records are derived tool "
281 "metadata, not page-authoring inputs. Retain the complete project "
282 "Design Spec and lock once per valid uncompacted execution context; "
283 "use page-context only for on-demand diagnostics or telemetry. "
284 "Read the selected complete prototype once per valid context and "
285 "again only after a known change or context invalidation. Choose "
286 "semantic replacements and edit only existing "
287 "visible text values; structured export validates text/tspan "
288 "topology and attributes against the prototype."
289 ),
290 "source_import": source_import or {
291 "warning_count": 0,
292 "by_code": {},
293 },
294 "templates": templates,
295 }
296 files.append(MaterializedFile(
297 Path("templates") / TEMPLATE_EXECUTION_MANIFEST_NAME,
298 _json_bytes(payload),
299 ))
300 return files
301
302
303 def _source_import_summary(import_workspace: Path) -> dict[str, object] | None:
304 """Summarize source-owned tolerant-import diagnostics by stable code."""
305 report_path = import_workspace / "conversion-report.json"
306 try:
307 report = json.loads(report_path.read_text(encoding="utf-8"))
308 except FileNotFoundError:
309 return None
310 except (OSError, json.JSONDecodeError) as exc:
311 raise MirrorMaterializationError(
312 f"Cannot read source conversion report {report_path}: {exc}"
313 ) from exc
314 diagnostics = report.get("diagnostics")
315 if not isinstance(diagnostics, list):
316 raise MirrorMaterializationError(
317 f"Source conversion report has no diagnostics array: {report_path}"
318 )
319 by_code: Counter[str] = Counter()
320 samples: dict[str, str] = {}
321 for item in diagnostics:
322 if not isinstance(item, dict):
323 continue
324 severity = str(item.get("severity") or "warning").lower()
325 if severity != "warning":
326 continue
327 code = str(item.get("code") or "unknown")
328 by_code[code] += 1
329 message = item.get("message")
330 if code not in samples and isinstance(message, str) and message:
331 samples[code] = message
332 return {
333 "warning_count": sum(by_code.values()),
334 "by_code": dict(sorted(by_code.items())),
335 "samples": dict(sorted(samples.items())),
336 }
337
338
339 def _local_name(name: object) -> str:
340 return name.rsplit("}", 1)[-1] if isinstance(name, str) else ""
341
342
343 def _axis_reflection_transform(value: str | None) -> str | None:
344 """Return one exact importer axis-reflection transform when valid."""
345 if not value:
346 return None
347 match = _AXIS_REFLECTION_RE.fullmatch(value)
348 if match is None:
349 return None
350 cx, cy, scale_x, scale_y, offset_x, offset_y = (
351 float(token) for token in match.groups()
352 )
353 if not (
354 math.isclose(abs(scale_x), 1.0, abs_tol=1e-9)
355 and math.isclose(abs(scale_y), 1.0, abs_tol=1e-9)
356 and (scale_x < 0 or scale_y < 0)
357 and math.isclose(offset_x, -cx, abs_tol=1e-7)
358 and math.isclose(offset_y, -cy, abs_tol=1e-7)
359 ):
360 return None
361 return value.strip()
362
363
364 def _compensate_reflected_group_text(root: ET.Element) -> int:
365 """Keep browser-visible text upright inside imported flipped groups."""
366 parent_by_child = {
367 child: parent
368 for parent in root.iter()
369 for child in parent
370 }
371 reflected_groups: list[tuple[int, ET.Element, str]] = []
372 for element in root.iter():
373 if _local_name(element.tag) != "g":
374 continue
375 transform = _axis_reflection_transform(element.get("transform"))
376 if transform is None:
377 continue
378 depth = 0
379 current = element
380 while current in parent_by_child:
381 depth += 1
382 current = parent_by_child[current]
383 reflected_groups.append((depth, element, transform))
384
385 wrapped = 0
386 for _depth, group, transform in sorted(
387 reflected_groups,
388 key=lambda item: item[0],
389 reverse=True,
390 ):
391 text_elements = [
392 element
393 for element in group.iter()
394 if _local_name(element.tag) == "text"
395 ]
396 for text in text_elements:
397 current_parents = {
398 child: parent
399 for parent in group.iter()
400 for child in parent
401 }
402 parent = current_parents.get(text)
403 if parent is None:
404 continue
405 position = list(parent).index(text)
406 parent.remove(text)
407 wrapper = ET.Element(
408 f"{{{SVG_NS}}}g",
409 {"transform": transform},
410 )
411 wrapper.append(text)
412 parent.insert(position, wrapper)
413 wrapped += 1
414 return wrapped
415
416
417 def _sha256_bytes(payload: bytes) -> str:
418 return hashlib.sha256(payload).hexdigest()
419
420
421 def _sha256_file(path: Path) -> str:
422 return _sha256_bytes(path.read_bytes())
423
424
425 def _load_json(path: Path, *, context: str) -> dict[str, Any]:
426 try:
427 payload = json.loads(path.read_text(encoding="utf-8"))
428 except OSError as exc:
429 raise MirrorMaterializationError(f"Cannot read {context}: {path}: {exc}") from exc
430 except json.JSONDecodeError as exc:
431 raise MirrorMaterializationError(
432 f"Invalid JSON in {context}: {path}: {exc}"
433 ) from exc
434 if not isinstance(payload, dict):
435 raise MirrorMaterializationError(f"{context} must be a JSON object: {path}")
436 return payload
437
438
439 def _require_list(value: object, *, context: str) -> list[Any]:
440 if not isinstance(value, list):
441 raise MirrorMaterializationError(f"{context} must be a list")
442 return value
443
444
445 def _require_string(value: object, *, context: str) -> str:
446 if not isinstance(value, str) or not value.strip():
447 raise MirrorMaterializationError(f"{context} must be a non-empty string")
448 return value.strip()
449
450
451 def _require_boolean(value: object, *, context: str) -> bool:
452 if not isinstance(value, bool):
453 raise MirrorMaterializationError(f"{context} must be a boolean")
454 return value
455
456
457 def _resolve_inside(root: Path, relative: str, *, context: str) -> Path:
458 parsed = urlsplit(relative)
459 if parsed.scheme or parsed.netloc or Path(parsed.path).is_absolute():
460 raise MirrorMaterializationError(f"{context} must be a relative path: {relative}")
461 resolved = (root / unquote(parsed.path)).resolve()
462 try:
463 resolved.relative_to(root.resolve())
464 except ValueError as exc:
465 raise MirrorMaterializationError(
466 f"{context} escapes its declared root: {relative}"
467 ) from exc
468 return resolved
469
470
471 def _parse_svg(path: Path) -> ET.Element:
472 parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True, insert_pis=True))
473 try:
474 root = ET.fromstring(path.read_bytes(), parser=parser)
475 except (OSError, ET.ParseError) as exc:
476 raise MirrorMaterializationError(f"Cannot parse SVG {path}: {exc}") from exc
477 if _local_name(root.tag) != "svg":
478 raise MirrorMaterializationError(f"SVG root is not <svg>: {path}")
479 return root
480
481
482 def _source_element(root: ET.Element, path: tuple[int, ...]) -> ET.Element:
483 element = root
484 try:
485 for index in path:
486 element = list(element)[index]
487 except (IndexError, TypeError) as exc:
488 raise MirrorMaterializationError(
489 f"Source-ref path no longer resolves: {list(path)}"
490 ) from exc
491 return element
492
493
494 def _source_identity(element: ET.Element) -> str | None:
495 scope = element.get("data-pptx-shape-scope")
496 shape_id = element.get("data-pptx-shape-id")
497 if not scope or not shape_id:
498 return None
499 return f"{scope}:{shape_id}"
500
501
502 def _load_authoring_documents(
503 workspace: Path,
504 ) -> tuple[Path, dict[str, AuthoringDocument]]:
505 authoring_root = workspace / "authoring-svg"
506 manifest_path = authoring_root / AUTHORING_MANIFEST_NAME
507 manifest = _load_json(manifest_path, context="authoring manifest")
508 if manifest.get("schema") != AUTHORING_SCHEMA:
509 raise MirrorMaterializationError(
510 f"Unsupported authoring manifest schema: {manifest.get('schema')!r}"
511 )
512 if manifest.get("projection_kind") != "layered":
513 raise MirrorMaterializationError(
514 "Mirror materialization requires projection_kind='layered'"
515 )
516 if manifest.get("authoring_root") != ".":
517 raise MirrorMaterializationError("authoring_manifest.json authoring_root must be '.'")
518 if manifest.get("source_ref_attribute") != SOURCE_REF_ATTRIBUTE:
519 raise MirrorMaterializationError(
520 "authoring_manifest.json uses an unsupported source-ref attribute"
521 )
522
523 source_root_raw = _require_string(
524 manifest.get("source_root"),
525 context="authoring_manifest.json source_root",
526 )
527 source_root = (authoring_root / source_root_raw).resolve()
528 expected_source_root = (workspace / "svg").resolve()
529 if source_root != expected_source_root:
530 raise MirrorMaterializationError(
531 "Type A mirror authoring manifest must resolve source_root to "
532 f"{expected_source_root}, found {source_root}"
533 )
534
535 documents_raw = _require_list(
536 manifest.get("documents"),
537 context="authoring_manifest.json documents",
538 )
539 documents: dict[str, AuthoringDocument] = {}
540 for index, raw in enumerate(documents_raw):
541 if not isinstance(raw, dict):
542 raise MirrorMaterializationError(f"documents[{index}] must be an object")
543 authoring_name = _require_string(
544 raw.get("authoring"),
545 context=f"documents[{index}].authoring",
546 )
547 source_name = _require_string(
548 raw.get("source"),
549 context=f"documents[{index}].source",
550 )
551 if authoring_name in documents:
552 raise MirrorMaterializationError(
553 f"Duplicate authoring manifest document: {authoring_name}"
554 )
555 authoring_path = _resolve_inside(
556 authoring_root,
557 authoring_name,
558 context=f"documents[{index}].authoring",
559 )
560 source_path = _resolve_inside(
561 source_root,
562 source_name,
563 context=f"documents[{index}].source",
564 )
565 if not authoring_path.is_file() or authoring_path.suffix.lower() != ".svg":
566 raise MirrorMaterializationError(
567 f"Authoring SVG is missing: {authoring_path}"
568 )
569 if not source_path.is_file() or source_path.suffix.lower() != ".svg":
570 raise MirrorMaterializationError(f"Lossless source SVG is missing: {source_path}")
571
572 expected_source_sha = _require_string(
573 raw.get("source_sha256"),
574 context=f"documents[{index}].source_sha256",
575 )
576 actual_source_sha = _sha256_file(source_path)
577 if actual_source_sha != expected_source_sha:
578 raise MirrorMaterializationError(
579 f"Lossless source SVG changed: {source_path.name}; expected "
580 f"{expected_source_sha}, found {actual_source_sha}"
581 )
582
583 refs_raw = raw.get("source_refs")
584 if not isinstance(refs_raw, dict):
585 raise MirrorMaterializationError(
586 f"documents[{index}].source_refs must be an object"
587 )
588 refs: dict[str, SourceRefRecord] = {}
589 source_root_element = _parse_svg(source_path)
590 for source_ref, ref_raw in refs_raw.items():
591 if not isinstance(source_ref, str) or not isinstance(ref_raw, dict):
592 raise MirrorMaterializationError(
593 f"Invalid source-ref record in {authoring_name}"
594 )
595 path_raw = ref_raw.get("source_path")
596 if not (
597 isinstance(path_raw, list)
598 and all(isinstance(item, int) and item >= 0 for item in path_raw)
599 ):
600 raise MirrorMaterializationError(
601 f"{authoring_name} source ref {source_ref!r} has invalid source_path"
602 )
603 initial_hash = _require_string(
604 ref_raw.get("initial_authoring_subtree_sha256"),
605 context=f"{authoring_name} source ref {source_ref!r} hash",
606 )
607 source_path_tuple = tuple(path_raw)
608 source_element = _source_element(source_root_element, source_path_tuple)
609 if _source_identity(source_element) != source_ref:
610 raise MirrorMaterializationError(
611 f"{authoring_name} source ref {source_ref!r} resolves to "
612 f"{_source_identity(source_element)!r}"
613 )
614 refs[source_ref] = SourceRefRecord(source_path_tuple, initial_hash)
615
616 documents[authoring_name] = AuthoringDocument(
617 name=authoring_name,
618 authoring_path=authoring_path,
619 source_path=source_path,
620 source_sha256=expected_source_sha,
621 source_refs=refs,
622 )
623
624 actual_authoring_files = {
625 path.relative_to(authoring_root).as_posix()
626 for path in authoring_root.rglob("*.svg")
627 if path.is_file()
628 }
629 if actual_authoring_files != set(documents):
630 raise MirrorMaterializationError(
631 "Authoring manifest/file roster differs; missing="
632 f"{sorted(set(documents) - actual_authoring_files)}, extra="
633 f"{sorted(actual_authoring_files - set(documents))}"
634 )
635 if manifest.get("file_count") != len(documents):
636 raise MirrorMaterializationError(
637 "authoring_manifest.json file_count does not match documents"
638 )
639 expected_ref_count = sum(len(document.source_refs) for document in documents.values())
640 if manifest.get("source_ref_count") != expected_ref_count:
641 raise MirrorMaterializationError(
642 "authoring_manifest.json source_ref_count does not match documents"
643 )
644 return authoring_root, documents
645
646
647 def _load_vector_assets(
648 workspace: Path,
649 documents: dict[str, AuthoringDocument],
650 ) -> dict[str, VectorAssetRecord]:
651 inventory_path = workspace / "authoring-svg_vector_asset_inventory.json"
652 if not inventory_path.exists():
653 return {}
654 inventory = _load_json(inventory_path, context="vector asset inventory")
655 if inventory.get("schema") != VECTOR_INVENTORY_SCHEMA:
656 raise MirrorMaterializationError(
657 f"Unsupported vector inventory schema: {inventory.get('schema')!r}"
658 )
659 if inventory.get("icon_namespace") != IMPORTED_ICON_NAMESPACE:
660 raise MirrorMaterializationError(
661 "Mirror vector inventory must use icon_namespace='imported'"
662 )
663 icons_root = workspace / "icons"
664 records: dict[str, VectorAssetRecord] = {}
665 for index, raw in enumerate(
666 _require_list(inventory.get("assets"), context="vector inventory assets")
667 ):
668 if not isinstance(raw, dict):
669 raise MirrorMaterializationError(f"vector assets[{index}] must be an object")
670 icon = _require_string(raw.get("icon"), context=f"assets[{index}].icon")
671 asset = _require_string(raw.get("asset"), context=f"assets[{index}].asset")
672 origin = _require_string(raw.get("svg"), context=f"assets[{index}].svg")
673 if not icon.startswith(f"{IMPORTED_ICON_NAMESPACE}/"):
674 raise MirrorMaterializationError(
675 f"Vector asset {icon!r} is outside imported/ namespace"
676 )
677 if icon in records:
678 raise MirrorMaterializationError(f"Duplicate vector asset id: {icon}")
679 if origin not in documents:
680 raise MirrorMaterializationError(
681 f"Vector asset {icon!r} names unknown origin document {origin!r}"
682 )
683 asset_path = _resolve_inside(
684 icons_root,
685 asset,
686 context=f"vector asset {icon!r}",
687 )
688 if not asset_path.is_file() or asset_path.suffix.lower() != ".svg":
689 raise MirrorMaterializationError(f"Vector asset is missing: {asset_path}")
690 expected_sha256 = _require_string(
691 raw.get("asset_sha256"),
692 context=f"vector asset {icon!r} asset_sha256",
693 )
694 actual_sha256 = _sha256_file(asset_path)
695 if actual_sha256 != expected_sha256:
696 raise MirrorMaterializationError(
697 f"Vector asset {icon!r} changed; expected {expected_sha256}, "
698 f"found {actual_sha256}"
699 )
700 refs_raw = _require_list(
701 raw.get("source_refs", []),
702 context=f"vector asset {icon!r} source_refs",
703 )
704 if not all(isinstance(item, str) and item for item in refs_raw):
705 raise MirrorMaterializationError(
706 f"Vector asset {icon!r} source_refs must be strings"
707 )
708 records[icon] = VectorAssetRecord(
709 icon=icon,
710 asset_path=asset_path,
711 origin_document=origin,
712 expected_sha256=expected_sha256,
713 source_refs=tuple(refs_raw),
714 )
715 if inventory.get("asset_count") != len(records):
716 raise MirrorMaterializationError("Vector inventory asset_count is stale")
717 return records
718
719
720 def _source_ref_counts(root: ET.Element) -> dict[str, int]:
721 counts: dict[str, int] = {}
722 for element in root.iter():
723 source_ref = element.get(SOURCE_REF_ATTRIBUTE)
724 if source_ref:
725 counts[source_ref] = counts.get(source_ref, 0) + 1
726 return counts
727
728
729 def _imported_icon_refs(root: ET.Element) -> set[str]:
730 return {
731 value
732 for element in root.iter()
733 if (value := (element.get("data-icon") or "").strip())
734 if value.startswith(f"{IMPORTED_ICON_NAMESPACE}/")
735 }
736
737
738 def _validate_source_ref_closure(
739 documents: dict[str, AuthoringDocument],
740 vector_assets: dict[str, VectorAssetRecord],
741 ) -> set[str]:
742 direct_counts: dict[str, dict[str, int]] = {}
743 referenced_icons: set[str] = set()
744 for name, document in documents.items():
745 root = _parse_svg(document.authoring_path)
746 direct_counts[name] = _source_ref_counts(root)
747 referenced_icons.update(_imported_icon_refs(root))
748
749 unknown_icons = sorted(referenced_icons - set(vector_assets))
750 if unknown_icons:
751 raise MirrorMaterializationError(
752 "Authoring SVG references missing imported vector asset(s): "
753 + ", ".join(unknown_icons)
754 )
755
756 asset_counts_by_document: dict[str, dict[str, int]] = {
757 name: {} for name in documents
758 }
759 for icon in sorted(referenced_icons):
760 record = vector_assets[icon]
761 root = _parse_svg(record.asset_path)
762 counts = _source_ref_counts(root)
763 if set(counts) != set(record.source_refs):
764 raise MirrorMaterializationError(
765 f"Vector asset {icon!r} source-ref inventory is stale; expected "
766 f"{sorted(record.source_refs)}, found {sorted(counts)}"
767 )
768 duplicate_refs = sorted(ref for ref, count in counts.items() if count != 1)
769 if duplicate_refs:
770 raise MirrorMaterializationError(
771 f"Vector asset {icon!r} contains duplicate source refs: "
772 + ", ".join(duplicate_refs)
773 )
774 origin_counts = asset_counts_by_document[record.origin_document]
775 for source_ref, count in counts.items():
776 origin_counts[source_ref] = origin_counts.get(source_ref, 0) + count
777
778 for name, document in documents.items():
779 combined = dict(direct_counts[name])
780 for source_ref, count in asset_counts_by_document[name].items():
781 combined[source_ref] = combined.get(source_ref, 0) + count
782 expected = set(document.source_refs)
783 actual = set(combined)
784 duplicates = sorted(ref for ref, count in combined.items() if count != 1)
785 if actual != expected or duplicates:
786 raise MirrorMaterializationError(
787 f"{name} source-ref closure differs from its manifest; missing="
788 f"{sorted(expected - actual)}, extra={sorted(actual - expected)}, "
789 f"duplicates={duplicates}"
790 )
791 return referenced_icons
792
793
794 def _load_native_graph(workspace: Path) -> dict[str, Any]:
795 native_path = workspace / "native_structure.json"
796 native = _load_json(native_path, context="native structure")
797 if native.get("schema") != NATIVE_STRUCTURE_SCHEMA:
798 raise MirrorMaterializationError(
799 f"Unsupported native structure schema: {native.get('schema')!r}"
800 )
801 source = native.get("source")
802 if not isinstance(source, dict):
803 raise MirrorMaterializationError("native_structure.json source must be an object")
804 template_name = _require_string(
805 source.get("templateFile"),
806 context="native_structure.json source.templateFile",
807 )
808 template_path = _resolve_inside(
809 workspace,
810 template_name,
811 context="native source template",
812 )
813 if not template_path.is_file():
814 raise MirrorMaterializationError(f"Native source template is missing: {template_path}")
815 expected_sha = _require_string(
816 source.get("sha256"),
817 context="native_structure.json source.sha256",
818 )
819 actual_sha = _sha256_file(template_path)
820 if actual_sha != expected_sha:
821 raise MirrorMaterializationError(
822 f"Native source template changed; expected {expected_sha}, found {actual_sha}"
823 )
824
825 slide_size = native.get("slideSize")
826 if not isinstance(slide_size, dict):
827 raise MirrorMaterializationError("native_structure.json slideSize is missing")
828 for field in ("width_px", "height_px"):
829 value = slide_size.get(field)
830 if not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
831 raise MirrorMaterializationError(f"native slideSize.{field} must be positive")
832 return native
833
834
835 def _load_inheritance(workspace: Path) -> dict[str, Any]:
836 return _load_json(workspace / "svg" / "inheritance.json", context="inheritance graph")
837
838
839 def _validate_graph_roster(
840 native: dict[str, Any],
841 inheritance: dict[str, Any],
842 documents: dict[str, AuthoringDocument],
843 ) -> None:
844 masters = _require_list(native.get("masters"), context="native masters")
845 layouts = _require_list(native.get("layouts"), context="native layouts")
846 slides = _require_list(native.get("slides"), context="native slides")
847 inheritance_masters_raw = _require_list(
848 inheritance.get("masters"),
849 context="inheritance masters",
850 )
851 master_file_by_part = {
852 _require_string(item.get("partPath"), context="inheritance Master partPath"):
853 _require_string(item.get("file"), context="inheritance Master file")
854 for item in inheritance_masters_raw
855 if isinstance(item, dict)
856 }
857 for index, master in enumerate(masters):
858 if not isinstance(master, dict):
859 raise MirrorMaterializationError(f"native masters[{index}] must be an object")
860 package_part = _require_string(
861 master.get("packagePart"),
862 context=f"native masters[{index}].packagePart",
863 )
864 svg_file = master_file_by_part.get(package_part)
865 if svg_file is None:
866 raise MirrorMaterializationError(
867 f"Native Master part {package_part!r} has no inheritance SVG file"
868 )
869 master["svgFile"] = svg_file
870 expected_files: set[str] = set()
871 for collection, field in (
872 (masters, "svgFile"),
873 (layouts, "svgFile"),
874 (slides, "layeredSvgFile"),
875 ):
876 for index, item in enumerate(collection):
877 if not isinstance(item, dict):
878 raise MirrorMaterializationError(f"native {field}[{index}] must be an object")
879 expected_files.add(
880 _require_string(item.get(field), context=f"native {field}[{index}]")
881 )
882 if expected_files != set(documents):
883 raise MirrorMaterializationError(
884 "Native graph and authoring document roster differ; missing="
885 f"{sorted(expected_files - set(documents))}, extra="
886 f"{sorted(set(documents) - expected_files)}"
887 )
888
889 inherited_masters = {
890 _require_string(item.get("file"), context="inheritance master file")
891 for item in inheritance_masters_raw
892 if isinstance(item, dict)
893 }
894 inherited_layouts = {
895 _require_string(item.get("file"), context="inheritance layout file"): item
896 for item in _require_list(inheritance.get("layouts"), context="inheritance layouts")
897 if isinstance(item, dict)
898 }
899 inherited_slides = {
900 int(item.get("index")): item
901 for item in _require_list(inheritance.get("slides"), context="inheritance slides")
902 if isinstance(item, dict) and isinstance(item.get("index"), int)
903 }
904 if inherited_masters != {item["svgFile"] for item in masters}:
905 raise MirrorMaterializationError("Inheritance Master roster differs from native graph")
906 if set(inherited_layouts) != {item["svgFile"] for item in layouts}:
907 raise MirrorMaterializationError("Inheritance Layout roster differs from native graph")
908 if set(inherited_slides) != {int(item["index"]) for item in slides}:
909 raise MirrorMaterializationError("Inheritance Slide roster differs from native graph")
910
911 master_file_by_key = {item["key"]: item["svgFile"] for item in masters}
912 layout_file_by_key = {item["key"]: item["svgFile"] for item in layouts}
913 for layout in layouts:
914 inherited = inherited_layouts[layout["svgFile"]]
915 if inherited.get("master") != master_file_by_key.get(layout.get("masterKey")):
916 raise MirrorMaterializationError(
917 f"Layout {layout.get('key')!r} Master parent differs across native facts"
918 )
919 inherited_visibility = _require_boolean(
920 inherited.get("showMasterShapes"),
921 context=f"inheritance Layout {layout.get('key')!r} showMasterShapes",
922 )
923 native_visibility = _require_boolean(
924 layout.get("showMasterShapes"),
925 context=f"native Layout {layout.get('key')!r} showMasterShapes",
926 )
927 if inherited_visibility != native_visibility:
928 raise MirrorMaterializationError(
929 f"Layout {layout.get('key')!r} showMasterShapes differs across facts"
930 )
931 for slide in slides:
932 inherited = inherited_slides[int(slide["index"])]
933 if inherited.get("layout") != layout_file_by_key.get(slide.get("layoutKey")):
934 raise MirrorMaterializationError(
935 f"Slide {slide.get('index')} Layout parent differs across native facts"
936 )
937 if inherited.get("master") != master_file_by_key.get(slide.get("masterKey")):
938 raise MirrorMaterializationError(
939 f"Slide {slide.get('index')} Master parent differs across native facts"
940 )
941 inherited_visibility = _require_boolean(
942 inherited.get("showInheritedShapes"),
943 context=f"inheritance Slide {slide.get('index')} showInheritedShapes",
944 )
945 native_visibility = _require_boolean(
946 slide.get("showInheritedShapes"),
947 context=f"native Slide {slide.get('index')} showInheritedShapes",
948 )
949 if inherited_visibility != native_visibility:
950 raise MirrorMaterializationError(
951 f"Slide {slide.get('index')} showInheritedShapes differs across facts"
952 )
953
954
955 def _absolutize_local_hrefs(root: ET.Element, base_dir: Path) -> None:
956 for element in root.iter():
957 for attribute in ("href", f"{{{XLINK_NS}}}href"):
958 value = element.get(attribute)
959 if not value or value.startswith("#"):
960 continue
961 parsed = urlsplit(value)
962 if parsed.scheme or parsed.netloc or not parsed.path:
963 continue
964 absolute = (base_dir / unquote(parsed.path)).resolve().as_posix()
965 element.set(
966 attribute,
967 urlunsplit(("", "", absolute, parsed.query, parsed.fragment)),
968 )
969
970
971 def _rehydrate_tree(
972 root: ET.Element,
973 document: AuthoringDocument,
974 *,
975 excluded_refs: set[str],
976 ) -> RestorationStats:
977 source_root = _parse_svg(document.source_path)
978 stats = RestorationStats()
979
980 def restore(element: ET.Element) -> ET.Element:
981 source_ref = element.get(SOURCE_REF_ATTRIBUTE)
982 record = document.source_refs.get(source_ref or "")
983 if source_ref and record is None:
984 raise MirrorMaterializationError(
985 f"{document.name} contains unknown source ref {source_ref!r}"
986 )
987 if source_ref and source_ref in excluded_refs:
988 stats.structural_refs += 1
989 elif source_ref and record is not None:
990 actual_hash = semantic_subtree_sha256(
991 element,
992 ignored_attributes=frozenset({SOURCE_REF_ATTRIBUTE}),
993 )
994 if actual_hash == record.initial_authoring_subtree_sha256:
995 restored = copy.deepcopy(_source_element(source_root, record.source_path))
996 _absolutize_local_hrefs(restored, document.source_path.parent)
997 stats.rehydrated_refs += 1
998 return restored
999 stats.fallback_refs += 1
1000
1001 children = list(element)
1002 for index, child in enumerate(children):
1003 replacement = restore(child)
1004 if replacement is child:
1005 continue
1006 replacement.tail = child.tail
1007 element.remove(child)
1008 element.insert(index, replacement)
1009 return element
1010
1011 restore(root)
1012 return stats
1013
1014
1015 def _line_groups(text: ET.Element) -> list[list[ET.Element]]:
1016 """Return direct tspan runs grouped by their imported visual line."""
1017 groups: list[list[ET.Element]] = []
1018 for tspan in text:
1019 if _local_name(tspan.tag) != "tspan":
1020 return []
1021 raw_dy = _optional_float(tspan.get("dy"))
1022 starts_line = (
1023 not groups
1024 or tspan.get("x") is not None
1025 or tspan.get("y") is not None
1026 or (raw_dy is not None and abs(raw_dy) > 1e-9)
1027 )
1028 if starts_line:
1029 groups.append([tspan])
1030 else:
1031 groups[-1].append(tspan)
1032 return groups
1033
1034
1035 def _normalized_text(value: str) -> str:
1036 return " ".join(value.split())
1037
1038
1039 def _text_body_segments(metadata: ET.Element) -> list[list[str]] | None:
1040 if metadata.get("data-pptx-encoding") != "base64":
1041 return None
1042 try:
1043 payload = base64.b64decode((metadata.text or "").strip(), validate=True)
1044 text_body = ET.fromstring(payload)
1045 except (ValueError, ET.ParseError):
1046 return None
1047
1048 paragraphs: list[list[str]] = []
1049 for paragraph in text_body:
1050 if _local_name(paragraph.tag) != "p":
1051 continue
1052 segments = [""]
1053 for child in paragraph:
1054 tag = _local_name(child.tag)
1055 if tag == "br":
1056 segments.append("")
1057 elif tag in {"r", "fld"}:
1058 segments[-1] += "".join(
1059 node.text or ""
1060 for node in child.iter()
1061 if _local_name(node.tag) == "t"
1062 )
1063 paragraphs.append(segments)
1064 return paragraphs
1065
1066
1067 def _mark_explicit_breaks(
1068 authored_element: ET.Element,
1069 source_element: ET.Element,
1070 ) -> None:
1071 metadata = next(
1072 (
1073 child
1074 for child in source_element
1075 if _local_name(child.tag) == "metadata"
1076 and child.get("data-pptx-part") == "txbody"
1077 ),
1078 None,
1079 )
1080 if metadata is None:
1081 return
1082 paragraphs = _text_body_segments(metadata)
1083 if paragraphs is None:
1084 return
1085 authored_texts = [
1086 child for child in authored_element if _local_name(child.tag) == "text"
1087 ]
1088 if len(authored_texts) != len(paragraphs):
1089 return
1090
1091 pending: list[ET.Element] = []
1092 for text, segments in zip(authored_texts, paragraphs):
1093 groups = _line_groups(text)
1094 if not groups:
1095 if len(segments) > 1:
1096 return
1097 continue
1098 line_texts = [
1099 _normalized_text(
1100 "".join(text for node in group for text in node.itertext())
1101 )
1102 for group in groups
1103 ]
1104 cursor = 0
1105 for segment_index, segment in enumerate(segments):
1106 target = _normalized_text(segment)
1107 combined = ""
1108 start = cursor
1109 while cursor < len(line_texts):
1110 combined = _normalized_text(" ".join((combined, line_texts[cursor])))
1111 cursor += 1
1112 if combined == target:
1113 break
1114 if target and not target.startswith(combined):
1115 return
1116 if combined != target:
1117 return
1118 if segment_index > 0 and start < len(groups):
1119 pending.append(groups[start][0])
1120 if cursor != len(groups):
1121 return
1122 for tspan in pending:
1123 tspan.set("data-paragraph-soft-break", "0")
1124
1125
1126 def _annotate_unchanged_explicit_text_breaks(
1127 root: ET.Element,
1128 document: AuthoringDocument,
1129 excluded_refs: set[str],
1130 ) -> None:
1131 """Recover native a:br semantics without restoring a stale txBody."""
1132 if not excluded_refs:
1133 return
1134 source_root = _parse_svg(document.source_path)
1135 for element in root.iter():
1136 source_ref = element.get(SOURCE_REF_ATTRIBUTE)
1137 record = document.source_refs.get(source_ref or "")
1138 if source_ref not in excluded_refs or record is None:
1139 continue
1140 actual_hash = semantic_subtree_sha256(
1141 element,
1142 ignored_attributes=frozenset({SOURCE_REF_ATTRIBUTE}),
1143 )
1144 if actual_hash != record.initial_authoring_subtree_sha256:
1145 continue
1146 _mark_explicit_breaks(
1147 element,
1148 _source_element(source_root, record.source_path),
1149 )
1150
1151
1152 def _prepared_document(
1153 document: AuthoringDocument,
1154 *,
1155 excluded_refs: set[str],
1156 ) -> tuple[ET.Element, RestorationStats]:
1157 root = _parse_svg(document.authoring_path)
1158 _absolutize_local_hrefs(root, document.authoring_path.parent)
1159 stats = _rehydrate_tree(root, document, excluded_refs=excluded_refs)
1160 _annotate_unchanged_explicit_text_breaks(root, document, excluded_refs)
1161 return root, stats
1162
1163
1164 def _safe_prefix(value: str) -> str:
1165 normalized = _SAFE_KEY_RE.sub("-", value).strip("-.")
1166 return normalized or "node"
1167
1168
1169 def _namespace_ids(root: ET.Element, prefix: str) -> None:
1170 mapping: dict[str, str] = {}
1171 for element in root.iter():
1172 element_id = element.get("id")
1173 if element_id:
1174 mapping[element_id] = f"{prefix}{_safe_prefix(element_id)}"
1175 for element in root.iter():
1176 element_id = element.get("id")
1177 if element_id:
1178 element.set("id", mapping[element_id])
1179 for name, value in list(element.attrib.items()):
1180 if name == "id":
1181 continue
1182 if value.startswith("#") and value[1:] in mapping:
1183 element.set(name, f"#{mapping[value[1:]]}")
1184 continue
1185 rewritten = _URL_REFERENCE_RE.sub(
1186 lambda match: f"url(#{mapping.get(match.group(2), match.group(2))})",
1187 value,
1188 )
1189 if name.rsplit("}", 1)[-1] in {"aria-describedby", "aria-labelledby"}:
1190 rewritten = " ".join(mapping.get(token, token) for token in rewritten.split())
1191 element.set(name, rewritten)
1192 if _local_name(element.tag) == "style" and element.text:
1193 element.text = _CSS_ID_RE.sub(
1194 lambda match: f"#{mapping.get(match.group(1), match.group(1))}",
1195 element.text,
1196 )
1197
1198
1199 def _parse_style(value: str | None) -> dict[str, str]:
1200 declarations: dict[str, str] = {}
1201 for raw in (value or "").split(";"):
1202 if ":" not in raw:
1203 continue
1204 name, declaration = raw.split(":", 1)
1205 if name.strip() and declaration.strip():
1206 declarations[name.strip().lower()] = declaration.strip()
1207 return declarations
1208
1209
1210 def _style_text(declarations: dict[str, str]) -> str:
1211 return ";".join(f"{name}:{value}" for name, value in declarations.items())
1212
1213
1214 def _hidden(element: ET.Element) -> bool:
1215 style = _parse_style(element.get("style"))
1216 display = (element.get("display") or style.get("display") or "").strip().lower()
1217 visibility = (
1218 element.get("visibility") or style.get("visibility") or ""
1219 ).strip().lower()
1220 opacity = (element.get("opacity") or style.get("opacity") or "").strip()
1221 if display == "none" or visibility in {"hidden", "collapse"}:
1222 return True
1223 try:
1224 return bool(opacity) and float(opacity) <= 0
1225 except ValueError:
1226 return False
1227
1228
1229 def _paint_value(element: ET.Element, name: str) -> str | None:
1230 value = element.get(name)
1231 if value is not None:
1232 return value.strip().lower()
1233 return _parse_style(element.get("style")).get(name)
1234
1235
1236 def _visible_leaf(element: ET.Element) -> bool:
1237 if _hidden(element):
1238 return False
1239 tag = _local_name(element.tag)
1240 if tag in _NON_VISUAL_TAGS:
1241 return False
1242 if tag in {"g", "svg", "a"}:
1243 return any(_visible_leaf(child) for child in element)
1244 if tag in {"rect", "image", "foreignObject"}:
1245 for dimension in ("width", "height"):
1246 raw = element.get(dimension)
1247 if raw is not None:
1248 try:
1249 if float(raw) <= 0:
1250 return False
1251 except ValueError:
1252 pass
1253 return True
1254 if tag in {"line", "polyline"}:
1255 stroke = _paint_value(element, "stroke")
1256 return stroke not in {None, "none", "transparent"}
1257 if tag in {"path", "polygon", "circle", "ellipse"}:
1258 fill = _paint_value(element, "fill")
1259 stroke = _paint_value(element, "stroke")
1260 return not (
1261 fill in {"none", "transparent"}
1262 and stroke in {None, "none", "transparent"}
1263 )
1264 if tag == "text":
1265 return bool("".join(element.itertext()).strip())
1266 return True
1267
1268
1269 def _merge_group_inheritance(parent: ET.Element, child: ET.Element) -> None:
1270 for name in _INHERITED_PRESENTATION_ATTRIBUTES:
1271 if parent.get(name) is not None and child.get(name) is None:
1272 child.set(name, parent.get(name, ""))
1273 parent_style = _parse_style(parent.get("style"))
1274 child_style = _parse_style(child.get("style"))
1275 if parent_style:
1276 merged = dict(parent_style)
1277 merged.update(child_style)
1278 child.set("style", _style_text(merged))
1279 parent_transform = (parent.get("transform") or "").strip()
1280 child_transform = (child.get("transform") or "").strip()
1281 if parent_transform:
1282 child.set(
1283 "transform",
1284 " ".join(item for item in (parent_transform, child_transform) if item),
1285 )
1286
1287
1288 def _flatten_fixed_group(group: ET.Element, *, context: str) -> list[ET.Element]:
1289 visible_children = [child for child in group if _visible_leaf(child)]
1290 group_style = _parse_style(group.get("style"))
1291 aggregate_attrs = {
1292 name for name in _AGGREGATE_GROUP_ATTRIBUTES if group.get(name) is not None
1293 }
1294 aggregate_attrs.update(
1295 name
1296 for name in _AGGREGATE_GROUP_ATTRIBUTES
1297 if name in group_style
1298 )
1299 if len(visible_children) > 1 and aggregate_attrs:
1300 raise MirrorMaterializationError(
1301 f"{context} cannot expand a multi-child group with aggregate effect(s): "
1302 + ", ".join(sorted(aggregate_attrs))
1303 )
1304
1305 atoms: list[ET.Element] = []
1306 for child in visible_children:
1307 child_style = _parse_style(child.get("style"))
1308 conflicting_aggregate_attrs = {
1309 name
1310 for name in aggregate_attrs
1311 if child.get(name) is not None or name in child_style
1312 }
1313 if conflicting_aggregate_attrs:
1314 raise MirrorMaterializationError(
1315 f"{context} cannot collapse nested aggregate effect(s): "
1316 + ", ".join(sorted(conflicting_aggregate_attrs))
1317 )
1318 item = copy.deepcopy(child)
1319 _merge_group_inheritance(group, item)
1320 for name in aggregate_attrs:
1321 if group.get(name) is not None:
1322 item.set(name, group.get(name, ""))
1323 if _local_name(item.tag) == "g":
1324 atoms.extend(_flatten_fixed_group(item, context=context))
1325 else:
1326 atoms.append(item)
1327 if len(atoms) == 1:
1328 atom = atoms[0]
1329 for name in ("data-pptx-frame", "data-pptx-object", "data-pptx-prst"):
1330 if group.get(name) is not None and atom.get(name) is None:
1331 atom.set(name, group.get(name, ""))
1332 return atoms
1333
1334
1335 def _flatten_fixed_text_atoms(atoms: Iterable[ET.Element]) -> list[ET.Element]:
1336 flattened: list[ET.Element] = []
1337 for atom in atoms:
1338 if _local_name(atom.tag) != "text":
1339 flattened.append(atom)
1340 continue
1341 scratch = ET.Element(f"{{{SVG_NS}}}svg")
1342 scratch.append(atom)
1343 tree = ET.ElementTree(scratch)
1344 flatten_text_with_tspans(tree, merge_paragraphs=False)
1345 flattened.extend(list(scratch))
1346 return flattened
1347
1348
1349 def _fixed_atoms(
1350 root: ET.Element,
1351 *,
1352 scope: str,
1353 key: str,
1354 placeholder_source_refs: set[str] | None = None,
1355 ) -> list[ET.Element]:
1356 atoms: list[ET.Element] = []
1357 serial = 0
1358 placeholder_source_refs = placeholder_source_refs or set()
1359 for child in root:
1360 tag = _local_name(child.tag)
1361 if (
1362 tag in _NON_VISUAL_TAGS
1363 or child.get("data-ph-type") is not None
1364 or child.get(SOURCE_REF_ATTRIBUTE) in placeholder_source_refs
1365 ):
1366 continue
1367 if not _visible_leaf(child):
1368 continue
1369 if tag == "g":
1370 expanded = _flatten_fixed_group(
1371 child,
1372 context=f"{scope} {key} element {child.get('id') or '<g>'}",
1373 )
1374 else:
1375 expanded = [copy.deepcopy(child)]
1376 expanded = _flatten_fixed_text_atoms(expanded)
1377 source_ref = child.get(SOURCE_REF_ATTRIBUTE)
1378 source_token = source_ref.split(":", 1)[-1] if source_ref else str(serial + 1)
1379 for part, atom in enumerate(expanded, start=1):
1380 serial += 1
1381 atom.set(
1382 "id",
1383 f"{scope}-{_safe_prefix(key)}-{_safe_prefix(source_token)}-{part}",
1384 )
1385 atom.set("data-pptx-layer", scope)
1386 atom.set("data-pptx-editable", "false")
1387 atom.attrib.pop("data-ph-type", None)
1388 atoms.append(atom)
1389 return atoms
1390
1391
1392 def _frame(element: ET.Element | None) -> tuple[float, float, float, float] | None:
1393 if element is None:
1394 return None
1395 raw = (element.get("data-pptx-frame") or "").replace(",", " ").split()
1396 if len(raw) != 4:
1397 return None
1398 try:
1399 values = tuple(float(item) for item in raw)
1400 except ValueError:
1401 return None
1402 if not all(math.isfinite(item) for item in values) or values[2] <= 0 or values[3] <= 0:
1403 return None
1404 return values
1405
1406
1407 def _element_by_ref(root: ET.Element, source_ref: str) -> ET.Element | None:
1408 matches = [
1409 element
1410 for element in root.iter()
1411 if element.get(SOURCE_REF_ATTRIBUTE) == source_ref
1412 ]
1413 if len(matches) > 1:
1414 raise MirrorMaterializationError(f"Duplicate source ref in document: {source_ref}")
1415 return matches[0] if matches else None
1416
1417
1418 def _placeholder_guide_by_semantic(
1419 root: ET.Element,
1420 semantic_role: str,
1421 ) -> ET.Element | None:
1422 matches = []
1423 for element in root.iter():
1424 placeholder_type = element.get("data-ph-type")
1425 if not placeholder_type:
1426 continue
1427 if _semantic_role({"type": placeholder_type}) == semantic_role:
1428 matches.append(element)
1429 if len(matches) == 1:
1430 return matches[0]
1431 if matches or semantic_role != "object":
1432 return None
1433
1434 body_matches = [
1435 element
1436 for element in root.iter()
1437 if element.get("data-ph-type") == "body"
1438 ]
1439 return body_matches[0] if len(body_matches) == 1 else None
1440
1441
1442 def _placeholder_idx(raw: object) -> int | None:
1443 if raw is None:
1444 return None
1445 try:
1446 value = int(str(raw))
1447 except ValueError as exc:
1448 raise MirrorMaterializationError(f"Invalid placeholder idx: {raw!r}") from exc
1449 if value < 0:
1450 raise MirrorMaterializationError(f"Placeholder idx must be non-negative: {raw!r}")
1451 return value
1452
1453
1454 def _semantic_role(placeholder: dict[str, Any]) -> str:
1455 role = str(placeholder.get("semanticRole") or "").strip()
1456 placeholder_type = str(placeholder.get("type") or "").strip()
1457 mapping = {
1458 "ctrTitle": "title",
1459 "title": "title",
1460 "subTitle": "subtitle",
1461 "body": "body",
1462 "dt": "date",
1463 "ftr": "footer",
1464 "sldNum": "slide-number",
1465 "pic": "picture",
1466 "chart": "chart",
1467 "tbl": "table",
1468 "media": "media",
1469 "obj": "object",
1470 }
1471 normalized = role or mapping.get(placeholder_type, "object")
1472 if normalized not in {
1473 "title",
1474 "subtitle",
1475 "body",
1476 "picture",
1477 "chart",
1478 "table",
1479 "object",
1480 "media",
1481 "date",
1482 "footer",
1483 "slide-number",
1484 }:
1485 raise MirrorMaterializationError(
1486 f"Unsupported placeholder semantic role: {normalized!r}"
1487 )
1488 return normalized
1489
1490
1491 def _placeholder_match(
1492 candidate: dict[str, Any],
1493 target: dict[str, Any],
1494 ) -> bool:
1495 candidate_idx = _placeholder_idx(candidate.get("idx"))
1496 target_idx = _placeholder_idx(target.get("idx"))
1497 if candidate_idx == target_idx and _semantic_role(candidate) == _semantic_role(target):
1498 return True
1499 return (
1500 candidate_idx == target_idx
1501 and str(candidate.get("type") or "") == str(target.get("type") or "")
1502 )
1503
1504
1505 def _placeholder_with_semantic_fallback(
1506 candidates: Iterable[dict[str, Any]],
1507 target: dict[str, Any],
1508 ) -> dict[str, Any] | None:
1509 candidate_list = list(candidates)
1510 exact = [item for item in candidate_list if _placeholder_match(item, target)]
1511 if len(exact) == 1:
1512 return exact[0]
1513 if len(exact) > 1:
1514 raise MirrorMaterializationError(
1515 f"Ambiguous placeholder identity for semantic role {_semantic_role(target)!r}"
1516 )
1517 semantic = [
1518 item
1519 for item in candidate_list
1520 if _semantic_role(item) == _semantic_role(target)
1521 ]
1522 return semantic[0] if len(semantic) == 1 else None
1523
1524
1525 def _native_geometry(placeholder: dict[str, Any]) -> tuple[float, float, float, float] | None:
1526 geometry = placeholder.get("geometry")
1527 if not isinstance(geometry, dict):
1528 return None
1529 try:
1530 values = tuple(float(geometry[name]) for name in ("x", "y", "width", "height"))
1531 except (KeyError, TypeError, ValueError):
1532 return None
1533 if not all(math.isfinite(item) for item in values) or values[2] <= 0 or values[3] <= 0:
1534 return None
1535 return values
1536
1537
1538 def _slot_plans(
1539 layout: dict[str, Any],
1540 layout_root: ET.Element,
1541 source_slides: list[dict[str, Any]],
1542 slide_roots: dict[int, ET.Element],
1543 master: dict[str, Any],
1544 master_root: ET.Element,
1545 ) -> list[SlotPlan]:
1546 plans: list[SlotPlan] = []
1547 placeholders = _require_list(
1548 layout.get("placeholders", []),
1549 context=f"layout {layout.get('key')} placeholders",
1550 )
1551 master_placeholders = _require_list(
1552 master.get("placeholders", []),
1553 context=f"master {master.get('key')} placeholders",
1554 )
1555 for raw in placeholders:
1556 if not isinstance(raw, dict):
1557 raise MirrorMaterializationError(
1558 f"Layout {layout.get('key')} placeholder must be an object"
1559 )
1560 shape_id = _require_string(
1561 raw.get("shapeId"),
1562 context=f"layout {layout.get('key')} placeholder shapeId",
1563 )
1564 bounds = _frame(_element_by_ref(layout_root, f"layout:{shape_id}"))
1565 if bounds is None:
1566 for slide in source_slides:
1567 matching = next(
1568 (
1569 item
1570 for item in slide.get("placeholders", [])
1571 if isinstance(item, dict) and _placeholder_match(item, raw)
1572 ),
1573 None,
1574 )
1575 if matching is None:
1576 continue
1577 slide_element = _element_by_ref(
1578 slide_roots[int(slide["index"])],
1579 f"slide:{matching['shapeId']}",
1580 )
1581 bounds = _frame(slide_element) or _native_geometry(matching)
1582 if bounds is not None:
1583 break
1584 if bounds is None:
1585 matching_master = _placeholder_with_semantic_fallback(
1586 (item for item in master_placeholders if isinstance(item, dict)),
1587 raw,
1588 )
1589 if matching_master is not None:
1590 master_element = _element_by_ref(
1591 master_root,
1592 f"master:{matching_master['shapeId']}",
1593 )
1594 bounds = _frame(master_element) or _native_geometry(matching_master)
1595 else:
1596 bounds = _frame(
1597 _placeholder_guide_by_semantic(master_root, _semantic_role(raw))
1598 )
1599 bounds = bounds or _native_geometry(raw)
1600 if bounds is None:
1601 raise MirrorMaterializationError(
1602 f"Layout {layout.get('key')!r} placeholder {shape_id!r} has no "
1603 "positive deterministic bounds in Layout, Slide, Master, or native facts"
1604 )
1605 plans.append(SlotPlan(
1606 slot_id=f"slot-{_safe_prefix(str(layout['key']))}-{_safe_prefix(shape_id)}",
1607 semantic_role=_semantic_role(raw),
1608 placeholder_type=(str(raw.get("type")) if raw.get("type") is not None else None),
1609 idx=_placeholder_idx(raw.get("idx")),
1610 shape_id=shape_id,
1611 bounds=bounds,
1612 ))
1613 effective_indices = [plan.idx if plan.idx is not None else 0 for plan in plans]
1614 if len(effective_indices) != len(set(effective_indices)):
1615 raise MirrorMaterializationError(
1616 f"Layout {layout.get('key')!r} has duplicate effective placeholder idx values"
1617 )
1618 return plans
1619
1620
1621 def _copy_text_carrier(source: ET.Element | None) -> ET.Element | None:
1622 if source is None:
1623 return None
1624 texts = [element for element in source.iter() if _local_name(element.tag) == "text"]
1625 if not texts:
1626 return None
1627 if len(texts) == 1:
1628 carrier = copy.deepcopy(texts[0])
1629 else:
1630 carrier = ET.Element(f"{{{SVG_NS}}}text", dict(texts[0].attrib))
1631 previous_y = _optional_float(texts[0].get("y"))
1632 first_output = True
1633 for text_index, text in enumerate(texts):
1634 text_y = _optional_float(text.get("y"))
1635 text_children = [
1636 child for child in text if _local_name(child.tag) == "tspan"
1637 ]
1638 if text_children:
1639 for child_index, child in enumerate(text_children):
1640 tspan = copy.deepcopy(child)
1641 tspan.attrib.pop("y", None)
1642 if first_output:
1643 tspan.attrib.pop("x", None)
1644 elif child_index == 0 and text_y is not None and previous_y is not None:
1645 tspan.set("x", text.get("x", texts[0].get("x", "0")))
1646 tspan.set("dy", format_coordinate(text_y - previous_y))
1647 if text_index > 0 and child_index == 0:
1648 tspan.set("data-paragraph-soft-break", "0")
1649 carrier.append(tspan)
1650 first_output = False
1651 elif text.text:
1652 attrs: dict[str, str] = {}
1653 if not first_output and text_y is not None and previous_y is not None:
1654 attrs["x"] = text.get("x", texts[0].get("x", "0"))
1655 attrs["dy"] = format_coordinate(text_y - previous_y)
1656 if text_index > 0:
1657 attrs["data-paragraph-soft-break"] = "0"
1658 tspan = ET.SubElement(carrier, f"{{{SVG_NS}}}tspan", attrs)
1659 tspan.text = text.text
1660 first_output = False
1661 if text_y is not None:
1662 previous_y = text_y
1663 carrier.attrib.pop("id", None)
1664 _normalize_mergeable_tspans(carrier)
1665 source_frame = _frame(source)
1666 if source_frame is not None:
1667 carrier.set(
1668 "data-pptx-frame",
1669 " ".join(format_coordinate(value) for value in source_frame),
1670 )
1671 carrier.set("data-pptx-carrier", "true")
1672 return carrier
1673
1674
1675 def _visible_placeholder_decoration(element: ET.Element) -> bool:
1676 """Return whether one non-text placeholder subtree paints any pixels."""
1677 if _hidden(element):
1678 return False
1679 tag = _local_name(element.tag)
1680 if tag in _NON_VISUAL_TAGS or tag == "text":
1681 return False
1682 if tag in {"g", "svg", "a"}:
1683 return any(_visible_placeholder_decoration(child) for child in element)
1684 if tag in {"image", "foreignObject", "use"}:
1685 return _visible_leaf(element)
1686 if tag in {"line", "polyline"}:
1687 stroke = _paint_value(element, "stroke")
1688 return stroke not in {None, "none", "transparent"}
1689 if tag in {"rect", "path", "polygon", "circle", "ellipse"}:
1690 fill = _paint_value(element, "fill")
1691 stroke = _paint_value(element, "stroke")
1692 filter_value = _paint_value(element, "filter")
1693 return (
1694 fill not in {"none", "transparent"}
1695 or stroke not in {None, "none", "transparent"}
1696 or filter_value not in {None, "none"}
1697 )
1698 return False
1699
1700
1701 def _placeholder_decorations(source: ET.Element | None) -> list[ET.Element]:
1702 """Copy direct visual children that decorate one text placeholder."""
1703 if source is None:
1704 return []
1705 decorations = [
1706 copy.deepcopy(child)
1707 for child in source
1708 if _visible_placeholder_decoration(child)
1709 ]
1710 for decoration in decorations:
1711 _merge_group_inheritance(source, decoration)
1712 _copy_placeholder_native_attributes(source, decorations)
1713 return decorations
1714
1715
1716 def _copy_placeholder_native_attributes(
1717 source: ET.Element,
1718 geometries: list[ET.Element],
1719 ) -> None:
1720 """Carry one logical placeholder geometry's native identity to its leaf."""
1721 if len(geometries) == 1:
1722 decoration = geometries[0]
1723 native_attributes = {
1724 "data-pptx-frame",
1725 "data-pptx-geometry-kind",
1726 "data-pptx-geometry-reason",
1727 "data-pptx-geometry-sha256",
1728 "data-pptx-geometry-status",
1729 "data-pptx-object",
1730 "data-pptx-prst",
1731 }
1732 for name, value in source.attrib.items():
1733 if name in native_attributes or name.startswith("data-pptx-av-"):
1734 if decoration.get(name) is None:
1735 decoration.set(name, value)
1736
1737
1738 def _placeholder_geometry(source: ET.Element | None) -> list[ET.Element]:
1739 """Copy direct source geometry even when it has no visible local paint."""
1740 if source is None:
1741 return []
1742 geometry_tags = {
1743 "circle",
1744 "ellipse",
1745 "line",
1746 "path",
1747 "polygon",
1748 "polyline",
1749 "rect",
1750 }
1751 geometries = [
1752 copy.deepcopy(child)
1753 for child in source
1754 if _local_name(child.tag) in geometry_tags and not _hidden(child)
1755 ]
1756 for geometry in geometries:
1757 _merge_group_inheritance(source, geometry)
1758 _copy_placeholder_native_attributes(source, geometries)
1759 return geometries
1760
1761
1762 def _apply_placeholder_paint(source: ET.Element, target: ET.Element) -> None:
1763 """Apply inherited paint without replacing Slide-owned geometry."""
1764 paint_attributes = {
1765 "color",
1766 "fill",
1767 "fill-opacity",
1768 "fill-rule",
1769 "filter",
1770 "mix-blend-mode",
1771 "opacity",
1772 "paint-order",
1773 "shape-rendering",
1774 "stroke",
1775 "stroke-dasharray",
1776 "stroke-dashoffset",
1777 "stroke-linecap",
1778 "stroke-linejoin",
1779 "stroke-miterlimit",
1780 "stroke-opacity",
1781 "stroke-width",
1782 "vector-effect",
1783 }
1784 source_style = _parse_style(source.get("style"))
1785 target_style = _parse_style(target.get("style"))
1786 for name in paint_attributes:
1787 value = source.get(name)
1788 if value is None:
1789 value = source_style.get(name)
1790 if value is None:
1791 continue
1792 target.set(name, value)
1793 target_style.pop(name, None)
1794 if target_style:
1795 target.set("style", _style_text(target_style))
1796 else:
1797 target.attrib.pop("style", None)
1798
1799
1800 def _remap_placeholder_decorations(
1801 decorations: list[ET.Element],
1802 from_frame: tuple[float, float, float, float] | None,
1803 to_frame: tuple[float, float, float, float] | None,
1804 ) -> None:
1805 """Map inherited decoration geometry onto the effective Slide frame."""
1806 if from_frame is None or to_frame is None:
1807 return
1808 if all(
1809 math.isclose(source, target, rel_tol=1e-9, abs_tol=1e-9)
1810 for source, target in zip(from_frame, to_frame)
1811 ):
1812 return
1813 from_x, from_y, from_width, from_height = from_frame
1814 to_x, to_y, to_width, to_height = to_frame
1815 scale_x = to_width / from_width
1816 scale_y = to_height / from_height
1817 translate_x = to_x - from_x * scale_x
1818 translate_y = to_y - from_y * scale_y
1819 frame_transform = "matrix({})".format(
1820 " ".join((
1821 _format_number(scale_x),
1822 "0",
1823 "0",
1824 _format_number(scale_y),
1825 format_coordinate(translate_x),
1826 format_coordinate(translate_y),
1827 ))
1828 )
1829 for decoration in decorations:
1830 existing = (decoration.get("transform") or "").strip()
1831 decoration.set(
1832 "transform",
1833 " ".join(value for value in (frame_transform, existing) if value),
1834 )
1835
1836
1837 def _resolved_placeholder_decorations(
1838 source: ET.Element | None,
1839 layout_guide: ET.Element | None,
1840 master_guide: ET.Element | None,
1841 ) -> list[ET.Element]:
1842 """Resolve text-placeholder decoration through Slide/Layout/Master."""
1843 local = _placeholder_decorations(source)
1844 local_geometry = (
1845 source is not None
1846 and source.get("data-pptx-placeholder-local-geometry") == "true"
1847 )
1848 if local and local_geometry:
1849 return local
1850
1851 inherited: list[ET.Element] = []
1852 inherited_guide: ET.Element | None = None
1853 for candidate in (layout_guide, master_guide):
1854 inherited = _placeholder_decorations(candidate)
1855 if inherited:
1856 inherited_guide = candidate
1857 break
1858 if not inherited:
1859 return local
1860
1861 geometry_tags = {
1862 "circle",
1863 "ellipse",
1864 "line",
1865 "path",
1866 "polygon",
1867 "polyline",
1868 "rect",
1869 }
1870 if local and not local_geometry:
1871 if (
1872 len(local) != 1
1873 or len(inherited) != 1
1874 or _local_name(local[0].tag) not in geometry_tags
1875 or _local_name(inherited[0].tag) not in geometry_tags
1876 ):
1877 return local
1878 _apply_placeholder_paint(local[0], inherited[0])
1879
1880 if (
1881 source is None
1882 or not local_geometry
1883 or len(inherited) != 1
1884 ):
1885 _remap_placeholder_decorations(
1886 inherited,
1887 _frame(inherited_guide),
1888 _frame(source),
1889 )
1890 return inherited
1891
1892 source_geometry = _placeholder_geometry(source)
1893 inherited_tag = _local_name(inherited[0].tag)
1894 if not source_geometry or inherited_tag not in geometry_tags:
1895 return inherited
1896 for geometry in source_geometry:
1897 _apply_placeholder_paint(inherited[0], geometry)
1898 return source_geometry
1899
1900
1901 def _normalize_mergeable_tspans(text: ET.Element) -> None:
1902 """Mark the first line of a positional text block for paragraph merging."""
1903 tspans = [child for child in text if _local_name(child.tag) == "tspan"]
1904 if len(tspans) < 2 or not any(
1905 any(tspan.get(name) is not None for name in ("x", "y", "dy"))
1906 for tspan in tspans
1907 ):
1908 return
1909 first = tspans[0]
1910 if first.get("x") is None and text.get("x") is not None:
1911 first.set("x", text.get("x", ""))
1912 if first.get("y") is None and first.get("dy") is None:
1913 first.set("dy", "0")
1914
1915
1916 def _blank_text_carrier(
1917 plan: SlotPlan,
1918 layout_guide: ET.Element | None,
1919 master_guide: ET.Element | None,
1920 ) -> ET.Element:
1921 carrier = _copy_text_carrier(layout_guide)
1922 if carrier is None:
1923 carrier = _copy_text_carrier(master_guide)
1924 if carrier is None:
1925 x, y, _width, height = plan.bounds
1926 carrier = ET.Element(
1927 f"{{{SVG_NS}}}text",
1928 {
1929 "x": format_coordinate(x),
1930 "y": format_coordinate(y + min(height, 24)),
1931 "font-size": "18",
1932 "fill": "#000000",
1933 "data-pptx-carrier": "true",
1934 },
1935 )
1936 carrier.text = None
1937 for child in list(carrier):
1938 carrier.remove(child)
1939 if carrier.get("data-pptx-frame") is None:
1940 carrier.set(
1941 "data-pptx-frame",
1942 " ".join(format_coordinate(value) for value in plan.bounds),
1943 )
1944 carrier.set("data-pptx-carrier", "true")
1945 return carrier
1946
1947
1948 def _blank_image_carrier(plan: SlotPlan) -> ET.Element:
1949 x, y, width, height = plan.bounds
1950 return ET.Element(
1951 f"{{{SVG_NS}}}image",
1952 {
1953 "x": format_coordinate(x),
1954 "y": format_coordinate(y),
1955 "width": format_coordinate(width),
1956 "height": format_coordinate(height),
1957 "href": TRANSPARENT_PIXEL_DATA_URI,
1958 "preserveAspectRatio": "none",
1959 "data-pptx-carrier": "true",
1960 },
1961 )
1962
1963
1964 def _format_number(value: float) -> str:
1965 return f"{value:.8f}".rstrip("0").rstrip(".") or "0"
1966
1967
1968 def _optional_float(value: str | None) -> float | None:
1969 if value is None:
1970 return None
1971 try:
1972 parsed = float(value)
1973 except ValueError:
1974 return None
1975 return parsed if math.isfinite(parsed) else None
1976
1977
1978 def _slot_wrapper(
1979 plan: SlotPlan,
1980 source: ET.Element | None,
1981 *,
1982 layout_guide: ET.Element | None,
1983 master_guide: ET.Element | None,
1984 ) -> tuple[ET.Element, list[ET.Element]]:
1985 wrapper = ET.Element(
1986 f"{{{SVG_NS}}}g",
1987 {
1988 "id": plan.slot_id,
1989 "data-pptx-placeholder": plan.semantic_role,
1990 "data-pptx-bounds": " ".join(
1991 format_coordinate(item) for item in plan.bounds
1992 ),
1993 },
1994 )
1995 if plan.idx is not None:
1996 wrapper.set("data-pptx-idx", str(plan.idx))
1997
1998 extras: list[ET.Element] = []
1999 if plan.semantic_role in {
2000 "title",
2001 "subtitle",
2002 "body",
2003 "date",
2004 "footer",
2005 "slide-number",
2006 }:
2007 carrier = _copy_text_carrier(source)
2008 if carrier is None:
2009 carrier = _blank_text_carrier(plan, layout_guide, master_guide)
2010 extras = _resolved_placeholder_decorations(
2011 source,
2012 layout_guide,
2013 master_guide,
2014 )
2015 for extra in extras:
2016 extra.attrib.pop("data-pptx-carrier", None)
2017 wrapper.append(carrier)
2018 return wrapper, extras
2019
2020 if plan.semantic_role == "object":
2021 proxy_source = source
2022 if proxy_source is None:
2023 proxy_source = layout_guide
2024 if proxy_source is None:
2025 proxy_source = master_guide
2026 if proxy_source is None:
2027 raise MirrorMaterializationError(
2028 f"Object slot {plan.slot_id!r} has no visible proxy source"
2029 )
2030 visible = [copy.deepcopy(child) for child in proxy_source if _visible_leaf(child)]
2031 if not visible:
2032 raise MirrorMaterializationError(
2033 f"Object slot {plan.slot_id!r} has no visible proxy content"
2034 )
2035 wrapper.set("data-pptx-binding", "proxy")
2036 for child in visible:
2037 wrapper.append(child)
2038 return wrapper, extras
2039
2040 expected_tags = {
2041 "picture": {"image", "svg"},
2042 "media": {"image", "svg"},
2043 "chart": {"g"},
2044 "table": {"g"},
2045 }[plan.semantic_role]
2046 carrier_source = source or layout_guide
2047 candidates = [
2048 element
2049 for element in (carrier_source.iter() if carrier_source is not None else [])
2050 if _local_name(element.tag) in expected_tags and _visible_leaf(element)
2051 ]
2052 if (
2053 not candidates
2054 and source is None
2055 and layout_guide is not None
2056 and plan.semantic_role in {"picture", "media"}
2057 ):
2058 wrapper.append(_blank_image_carrier(plan))
2059 return wrapper, extras
2060 if len(candidates) != 1:
2061 raise MirrorMaterializationError(
2062 f"Slot {plan.slot_id!r} requires exactly one visible "
2063 f"{plan.semantic_role} carrier, found {len(candidates)}"
2064 )
2065 carrier = copy.deepcopy(candidates[0])
2066 if plan.semantic_role in {"chart", "table"}:
2067 marker = carrier.get("data-pptx-replace-with")
2068 if marker != plan.semantic_role:
2069 raise MirrorMaterializationError(
2070 f"Slot {plan.slot_id!r} {plan.semantic_role} carrier lacks native marker"
2071 )
2072 carrier.set("data-pptx-carrier", "true")
2073 wrapper.append(carrier)
2074 return wrapper, extras
2075
2076
2077 def _strip_source_refs(root: ET.Element) -> None:
2078 for element in root.iter():
2079 element.attrib.pop(SOURCE_REF_ATTRIBUTE, None)
2080 element.attrib.pop("data-ph-type", None)
2081
2082
2083 def _is_full_canvas_rect(
2084 element: ET.Element,
2085 width: float,
2086 height: float,
2087 ) -> bool:
2088 if _local_name(element.tag) != "rect":
2089 return False
2090 try:
2091 values = tuple(
2092 float(element.get(name, default))
2093 for name, default in (
2094 ("x", "0"),
2095 ("y", "0"),
2096 ("width", "0"),
2097 ("height", "0"),
2098 )
2099 )
2100 except ValueError:
2101 return False
2102 fill = _paint_value(element, "fill")
2103 return (
2104 math.isclose(values[0], 0, abs_tol=0.01)
2105 and math.isclose(values[1], 0, abs_tol=0.01)
2106 and math.isclose(values[2], width, abs_tol=0.01)
2107 and math.isclose(values[3], height, abs_tol=0.01)
2108 and fill not in {None, "none", "transparent"}
2109 )
2110
2111
2112 def _non_visual_nodes(roots: Iterable[ET.Element]) -> tuple[ET.Element | None, list[ET.Element]]:
2113 definitions: list[ET.Element] = []
2114 styles: list[ET.Element] = []
2115 for root in roots:
2116 for child in root:
2117 tag = _local_name(child.tag)
2118 if tag == "defs":
2119 definitions.extend(copy.deepcopy(list(child)))
2120 elif tag == "style":
2121 styles.append(copy.deepcopy(child))
2122 defs_node = ET.Element(f"{{{SVG_NS}}}defs") if definitions else None
2123 if defs_node is not None:
2124 for definition in definitions:
2125 defs_node.append(definition)
2126 return defs_node, styles
2127
2128
2129 def _slide_placeholder_map(slide: dict[str, Any]) -> dict[str, dict[str, Any]]:
2130 return {
2131 f"slide:{item['shapeId']}": item
2132 for item in slide.get("placeholders", [])
2133 if isinstance(item, dict) and item.get("shapeId") is not None
2134 }
2135
2136
2137 def _matching_slide_placeholder(
2138 slide: dict[str, Any],
2139 layout_placeholder: dict[str, Any],
2140 ) -> dict[str, Any] | None:
2141 matches = [
2142 item
2143 for item in slide.get("placeholders", [])
2144 if isinstance(item, dict) and _placeholder_match(item, layout_placeholder)
2145 ]
2146 if len(matches) > 1:
2147 raise MirrorMaterializationError(
2148 f"Slide {slide.get('index')} ambiguously matches Layout placeholder "
2149 f"{layout_placeholder.get('shapeId')}"
2150 )
2151 return matches[0] if matches else None
2152
2153
2154 def _compose_template(
2155 *,
2156 native: dict[str, Any],
2157 master: dict[str, Any],
2158 layout: dict[str, Any],
2159 master_root: ET.Element,
2160 layout_root: ET.Element,
2161 slide: dict[str, Any] | None,
2162 slide_root: ET.Element | None,
2163 slot_plans: list[SlotPlan],
2164 ) -> ET.Element:
2165 width = float(native["slideSize"]["width_px"])
2166 height = float(native["slideSize"]["height_px"])
2167 root = ET.Element(
2168 f"{{{SVG_NS}}}svg",
2169 {
2170 "version": "1.1",
2171 "width": format_coordinate(width),
2172 "height": format_coordinate(height),
2173 "viewBox": (
2174 f"0 0 {format_coordinate(width)} {format_coordinate(height)}"
2175 ),
2176 "data-pptx-master": str(master["key"]),
2177 "data-pptx-master-name": str(master["name"]),
2178 "data-pptx-layout": str(layout["key"]),
2179 "data-pptx-layout-name": str(layout["name"]),
2180 "data-pptx-show-master-shapes": str(
2181 _require_boolean(
2182 layout.get("showMasterShapes"),
2183 context=f"Layout {layout.get('key')!r} showMasterShapes",
2184 )
2185 ).lower(),
2186 "data-pptx-show-inherited-shapes": str(
2187 _require_boolean(
2188 slide.get("showInheritedShapes"),
2189 context=f"Slide {slide.get('index')} showInheritedShapes",
2190 )
2191 if slide
2192 else True
2193 ).lower(),
2194 },
2195 )
2196
2197 roots = [master_root, layout_root]
2198 if slide_root is not None:
2199 roots.append(slide_root)
2200 defs, styles = _non_visual_nodes(roots)
2201 if defs is not None:
2202 root.append(defs)
2203 for style in styles:
2204 root.append(style)
2205
2206 master_atoms = _fixed_atoms(master_root, scope="master", key=str(master["key"]))
2207 layout_placeholder_refs = {
2208 f"layout:{item['shapeId']}"
2209 for item in layout.get("placeholders", [])
2210 if isinstance(item, dict) and item.get("shapeId") is not None
2211 }
2212 layout_atoms = _fixed_atoms(
2213 layout_root,
2214 scope="layout",
2215 key=str(layout["key"]),
2216 placeholder_source_refs=layout_placeholder_refs,
2217 )
2218 master_backgrounds = [
2219 atom for atom in master_atoms if _is_full_canvas_rect(atom, width, height)
2220 ]
2221 layout_backgrounds = [
2222 atom for atom in layout_atoms if _is_full_canvas_rect(atom, width, height)
2223 ]
2224 master_shapes = [atom for atom in master_atoms if atom not in master_backgrounds]
2225 layout_shapes = [atom for atom in layout_atoms if atom not in layout_backgrounds]
2226
2227 slide_backgrounds: list[ET.Element] = []
2228 slide_content: list[ET.Element] = []
2229 source_placeholder_elements: dict[str, ET.Element] = {}
2230 if slide_root is not None and slide is not None:
2231 placeholder_refs = _slide_placeholder_map(slide)
2232 for index, child in enumerate(slide_root):
2233 if _local_name(child.tag) in _NON_VISUAL_TAGS:
2234 continue
2235 source_ref = child.get(SOURCE_REF_ATTRIBUTE)
2236 if source_ref in placeholder_refs:
2237 source_placeholder_elements[source_ref] = child
2238 continue
2239 item = copy.deepcopy(child)
2240 if _is_full_canvas_rect(item, width, height):
2241 item.set("id", item.get("id") or f"slide-{slide['index']}-background")
2242 item.set("data-pptx-layer", "slide")
2243 item.set("data-pptx-editable", "false")
2244 slide_backgrounds.append(item)
2245 else:
2246 item.set("id", item.get("id") or f"slide-{slide['index']}-node-{index + 1}")
2247 slide_content.append(item)
2248 if len(slide_backgrounds) > 1:
2249 raise MirrorMaterializationError(
2250 f"Slide {slide['index']} has more than one full-canvas solid background"
2251 )
2252
2253 layout_placeholders = {
2254 str(item["shapeId"]): item
2255 for item in layout.get("placeholders", [])
2256 if isinstance(item, dict) and item.get("shapeId") is not None
2257 }
2258 master_placeholders = [
2259 item for item in master.get("placeholders", []) if isinstance(item, dict)
2260 ]
2261 slots: list[ET.Element] = []
2262 slot_extras: list[ET.Element] = []
2263 for plan in slot_plans:
2264 layout_placeholder = layout_placeholders[plan.shape_id]
2265 layout_guide = _element_by_ref(layout_root, f"layout:{plan.shape_id}")
2266 master_placeholder = _placeholder_with_semantic_fallback(
2267 master_placeholders,
2268 layout_placeholder,
2269 )
2270 master_guide = (
2271 _element_by_ref(master_root, f"master:{master_placeholder['shapeId']}")
2272 if master_placeholder is not None
2273 else _placeholder_guide_by_semantic(
2274 master_root,
2275 plan.semantic_role,
2276 )
2277 )
2278 source_element = None
2279 if slide is not None:
2280 slide_placeholder = _matching_slide_placeholder(slide, layout_placeholder)
2281 if slide_placeholder is not None:
2282 source_element = source_placeholder_elements.get(
2283 f"slide:{slide_placeholder['shapeId']}"
2284 )
2285 if source_element is None:
2286 raise MirrorMaterializationError(
2287 f"Slide {slide['index']} placeholder {slide_placeholder['shapeId']} "
2288 "is missing from its authoring SVG"
2289 )
2290 wrapper, extras = _slot_wrapper(
2291 plan,
2292 source_element,
2293 layout_guide=layout_guide,
2294 master_guide=master_guide,
2295 )
2296 slots.append(wrapper)
2297 slot_extras.extend(extras)
2298
2299 for element in (
2300 *master_backgrounds,
2301 *layout_backgrounds,
2302 *slide_backgrounds,
2303 *master_shapes,
2304 *layout_shapes,
2305 *slide_content,
2306 *slot_extras,
2307 *slots,
2308 ):
2309 _strip_source_refs(element)
2310 root.append(element)
2311 _strip_source_refs(root)
2312 return root
2313
2314
2315 def _page_type(slide: dict[str, Any]) -> str:
2316 raw = str(slide.get("pageType") or "").strip().lower()
2317 mapping = {
2318 "cover_candidate": "cover",
2319 "toc_candidate": "toc",
2320 "chapter_candidate": "chapter",
2321 "content_candidate": "content",
2322 "ending_candidate": "ending",
2323 }
2324 return mapping.get(raw, "content")
2325
2326
2327 def _serialize_svg(root: ET.Element) -> bytes:
2328 payload = ET.tostring(root, encoding="utf-8", xml_declaration=False)
2329 return payload if payload.endswith(b"\n") else payload + b"\n"
2330
2331
2332 def _refresh_preset_preview_hashes(root: ET.Element) -> None:
2333 """Rebind imported preset guards after deterministic SVG ID namespacing."""
2334 for element in root.iter():
2335 if (
2336 _local_name(element.tag) == "g"
2337 and element.get("data-pptx-object") in {"shape", "connector"}
2338 and element.get("data-pptx-prst") is not None
2339 and element.get("data-pptx-preview-sha256") is not None
2340 ):
2341 fingerprint = svg_preset_preview_fingerprint(element)
2342 element.set("data-pptx-preview-sha256", fingerprint)
2343 for descendant in element.iter():
2344 if descendant.get("data-pptx-preview-sha256") is not None:
2345 descendant.set("data-pptx-preview-sha256", fingerprint)
2346
2347
2348 def _sanitize_connector_references(root: ET.Element) -> int:
2349 """Drop endpoint bindings whose native target is absent from this SVG tree."""
2350 identities = {
2351 (
2352 element.get("data-pptx-shape-scope"),
2353 element.get("data-pptx-shape-id"),
2354 )
2355 for element in root.iter()
2356 if element.get("data-pptx-shape-scope")
2357 and element.get("data-pptx-shape-id")
2358 }
2359 detached: set[tuple[str, str, str]] = set()
2360 for element in root.iter():
2361 connector_scope = element.get("data-pptx-shape-scope") or "unknown"
2362 connector_id = element.get("data-pptx-shape-id") or element.get("id") or "unknown"
2363 for endpoint in ("start", "end"):
2364 target_id_attr = f"data-pptx-{endpoint}-shape-id"
2365 target_id = element.get(target_id_attr)
2366 if target_id is None:
2367 continue
2368 target_scope = element.get(
2369 f"data-pptx-{endpoint}-shape-scope",
2370 connector_scope,
2371 )
2372 if (target_scope, target_id) in identities:
2373 continue
2374 detached.add((connector_scope, connector_id, endpoint))
2375 for suffix in ("shape-id", "shape-scope", "site"):
2376 element.attrib.pop(f"data-pptx-{endpoint}-{suffix}", None)
2377 return len(detached)
2378
2379
2380 def _local_asset_path(value: str, base_dir: Path) -> Path | None:
2381 if not value or value.startswith("#"):
2382 return None
2383 parsed = urlsplit(value)
2384 if parsed.scheme == "file":
2385 return Path(unquote(parsed.path)).resolve()
2386 if parsed.scheme or parsed.netloc or not parsed.path:
2387 return None
2388 path = Path(unquote(parsed.path))
2389 return path.resolve() if path.is_absolute() else (base_dir / path).resolve()
2390
2391
2392 def _detected_bitmap_extension(path: Path) -> str | None:
2393 header = path.read_bytes()[:32]
2394 if header.startswith(b"\x89PNG\r\n\x1a\n"):
2395 return ".png"
2396 if header.startswith(b"\xff\xd8\xff"):
2397 return ".jpg"
2398 if header.startswith((b"GIF87a", b"GIF89a")):
2399 return ".gif"
2400 if header.startswith(b"BM"):
2401 return ".bmp"
2402 if header.startswith((b"II*\x00", b"MM\x00*")):
2403 return ".tiff"
2404 if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WEBP":
2405 return ".webp"
2406 return None
2407
2408
2409 def _rewrite_packaged_assets(
2410 root: ET.Element,
2411 *,
2412 source_base: Path,
2413 final_svg_path: Path,
2414 template_workspace: Path,
2415 asset_sources: dict[Path, Path],
2416 ) -> None:
2417 for element in root.iter():
2418 for attribute in ("href", f"{{{XLINK_NS}}}href"):
2419 value = element.get(attribute)
2420 if value is None:
2421 continue
2422 source = _local_asset_path(value, source_base)
2423 if source is None:
2424 continue
2425 if not source.is_file():
2426 raise MirrorMaterializationError(
2427 f"Referenced local asset is missing: {source}"
2428 )
2429 detected_extension = _detected_bitmap_extension(source)
2430 if detected_extension is not None:
2431 source_suffix = source.suffix.lower()
2432 equivalent_suffixes = (
2433 {".jpg", ".jpeg"}
2434 if detected_extension == ".jpg"
2435 else {detected_extension}
2436 )
2437 packaged_name = (
2438 source.name
2439 if source_suffix in equivalent_suffixes
2440 else f"{source.stem}{detected_extension}"
2441 )
2442 relative_target = Path("images") / packaged_name
2443 elif source.suffix.lower() in _BITMAP_EXTENSIONS:
2444 relative_target = Path("images") / source.name
2445 else:
2446 relative_target = Path("templates") / "assets" / source.name
2447 previous = asset_sources.get(relative_target)
2448 if previous is not None and _sha256_file(previous) != _sha256_file(source):
2449 raise MirrorMaterializationError(
2450 f"Asset basename collision with different content: {source.name}"
2451 )
2452 asset_sources[relative_target] = source
2453 target = template_workspace / relative_target
2454 relative_href = os.path.relpath(target, final_svg_path.parent).replace(os.sep, "/")
2455 parsed = urlsplit(value)
2456 element.set(
2457 attribute,
2458 urlunsplit(("", "", relative_href, parsed.query, parsed.fragment)),
2459 )
2460
2461
2462 def _materialize_icon(
2463 record: VectorAssetRecord,
2464 document: AuthoringDocument,
2465 ) -> tuple[ET.Element, RestorationStats]:
2466 actual_sha256 = _sha256_file(record.asset_path)
2467 if actual_sha256 != record.expected_sha256:
2468 raise MirrorMaterializationError(
2469 f"Vector asset {record.icon!r} changed during materialization; "
2470 f"expected {record.expected_sha256}, found {actual_sha256}"
2471 )
2472 root = _parse_svg(record.asset_path)
2473 _absolutize_local_hrefs(root, record.asset_path.parent)
2474 stats = _rehydrate_tree(root, document, excluded_refs=set())
2475 _strip_source_refs(root)
2476 return root, stats
2477
2478
2479 def _ensure_no_source_refs(path: Path) -> None:
2480 root = _parse_svg(path)
2481 refs = sorted(_source_ref_counts(root))
2482 if refs:
2483 raise MirrorMaterializationError(
2484 f"Materialized file still contains source refs: {path}: {refs[:5]}"
2485 )
2486
2487
2488 def _preflight_output(
2489 import_workspace: Path,
2490 template_workspace: Path,
2491 relative_files: Iterable[Path],
2492 ) -> None:
2493 import_resolved = import_workspace.resolve()
2494 output_resolved = template_workspace.resolve()
2495 try:
2496 output_resolved.relative_to(import_resolved)
2497 except ValueError:
2498 pass
2499 else:
2500 raise MirrorMaterializationError(
2501 "Template workspace must not be inside the import workspace"
2502 )
2503 try:
2504 import_resolved.relative_to(output_resolved)
2505 except ValueError:
2506 pass
2507 else:
2508 raise MirrorMaterializationError(
2509 "Import workspace must not be inside the template workspace"
2510 )
2511
2512 templates_root = template_workspace / "templates"
2513 if templates_root.exists():
2514 if not templates_root.is_dir():
2515 raise MirrorMaterializationError(
2516 f"Template output is not a directory: {templates_root}"
2517 )
2518 existing = sorted(path for path in templates_root.iterdir())
2519 if existing:
2520 raise MirrorMaterializationError(
2521 f"Template output must be empty before mirror materialization: "
2522 f"{templates_root}; first entry: {existing[0].name}"
2523 )
2524 collisions = [
2525 template_workspace / relative
2526 for relative in relative_files
2527 if os.path.lexists(template_workspace / relative)
2528 ]
2529 if collisions:
2530 raise MirrorMaterializationError(
2531 f"Output file already exists: {collisions[0]}"
2532 )
2533
2534
2535 def _nearest_existing_directory(path: Path) -> Path:
2536 candidate = path
2537 while not candidate.exists():
2538 if candidate.parent == candidate:
2539 break
2540 candidate = candidate.parent
2541 if not candidate.is_dir():
2542 raise MirrorMaterializationError(f"Output parent is not a directory: {candidate}")
2543 return candidate
2544
2545
2546 def _ensure_directory(path: Path, created: list[Path]) -> None:
2547 missing: list[Path] = []
2548 candidate = path
2549 while not candidate.exists():
2550 if os.path.lexists(candidate):
2551 raise MirrorMaterializationError(
2552 f"Output parent is not a directory: {candidate}"
2553 )
2554 missing.append(candidate)
2555 candidate = candidate.parent
2556 if not candidate.is_dir():
2557 raise MirrorMaterializationError(f"Output parent is not a directory: {candidate}")
2558 for directory in reversed(missing):
2559 directory.mkdir()
2560 created.append(directory)
2561
2562
2563 def _publish_files(
2564 template_workspace: Path,
2565 staged_root: Path,
2566 relative_files: list[Path],
2567 ) -> None:
2568 created_dirs: list[Path] = []
2569 published: list[Path] = []
2570 try:
2571 for relative in relative_files:
2572 target = template_workspace / relative
2573 _ensure_directory(target.parent, created_dirs)
2574 staging_device = staged_root.stat().st_dev
2575 for relative in relative_files:
2576 target = template_workspace / relative
2577 if target.parent.stat().st_dev != staging_device:
2578 raise MirrorMaterializationError(
2579 f"Cannot atomically publish across filesystems: {target}"
2580 )
2581 if os.path.lexists(target):
2582 raise MirrorMaterializationError(
2583 f"Output appeared while mirror files were staged: {target}"
2584 )
2585 for relative in relative_files:
2586 target = template_workspace / relative
2587 (staged_root / relative).replace(target)
2588 published.append(target)
2589 except (OSError, MirrorMaterializationError) as exc:
2590 rollback_errors: list[str] = []
2591 for path in reversed(published):
2592 try:
2593 path.unlink(missing_ok=True)
2594 except OSError as rollback_exc:
2595 rollback_errors.append(f"could not remove {path}: {rollback_exc}")
2596 for directory in reversed(created_dirs):
2597 try:
2598 directory.rmdir()
2599 except OSError:
2600 pass
2601 if rollback_errors:
2602 raise MirrorMaterializationError(
2603 f"Mirror publish failed ({exc}); rollback was incomplete: "
2604 + "; ".join(rollback_errors)
2605 ) from exc
2606 raise MirrorMaterializationError(f"Mirror publish failed: {exc}") from exc
2607
2608
2609 def materialize_mirror_template(
2610 import_workspace: Path,
2611 template_workspace: Path,
2612 ) -> dict[str, Any]:
2613 """Validate one Type A import graph and publish its mirror SVG contract."""
2614 authoring_root, documents = _load_authoring_documents(import_workspace)
2615 vector_assets = _load_vector_assets(import_workspace, documents)
2616 referenced_icons = _validate_source_ref_closure(documents, vector_assets)
2617 native = _load_native_graph(import_workspace)
2618 inheritance = _load_inheritance(import_workspace)
2619 _validate_graph_roster(native, inheritance, documents)
2620
2621 masters = {item["key"]: item for item in native["masters"]}
2622 layouts = {item["key"]: item for item in native["layouts"]}
2623 slides = sorted(native["slides"], key=lambda item: int(item["index"]))
2624 if [int(item["index"]) for item in slides] != list(range(1, len(slides) + 1)):
2625 raise MirrorMaterializationError(
2626 "Mirror source slide indexes must be contiguous and start at 1"
2627 )
2628
2629 prepared_masters: dict[str, ET.Element] = {}
2630 prepared_layouts: dict[str, ET.Element] = {}
2631 prepared_slides: dict[int, ET.Element] = {}
2632 total_stats = RestorationStats()
2633 for key, master in masters.items():
2634 document = documents[master["svgFile"]]
2635 root, stats = _prepared_document(
2636 document,
2637 excluded_refs=set(document.source_refs),
2638 )
2639 _namespace_ids(root, f"m-{_safe_prefix(str(key))}-")
2640 prepared_masters[key] = root
2641 total_stats.merge(stats)
2642 for key, layout in layouts.items():
2643 document = documents[layout["svgFile"]]
2644 root, stats = _prepared_document(
2645 document,
2646 excluded_refs=set(document.source_refs),
2647 )
2648 _namespace_ids(root, f"l-{_safe_prefix(str(key))}-")
2649 prepared_layouts[key] = root
2650 total_stats.merge(stats)
2651 for slide in slides:
2652 index = int(slide["index"])
2653 document = documents[slide["layeredSvgFile"]]
2654 placeholder_refs = {
2655 f"slide:{item['shapeId']}"
2656 for item in slide.get("placeholders", [])
2657 if isinstance(item, dict) and item.get("shapeId") is not None
2658 }
2659 root, stats = _prepared_document(document, excluded_refs=placeholder_refs)
2660 _namespace_ids(root, f"s-{index:03d}-")
2661 prepared_slides[index] = root
2662 total_stats.merge(stats)
2663
2664 slides_by_layout: dict[str, list[dict[str, Any]]] = {key: [] for key in layouts}
2665 for slide in slides:
2666 slides_by_layout[str(slide["layoutKey"])].append(slide)
2667
2668 plans_by_layout: dict[str, list[SlotPlan]] = {}
2669 for key, layout in layouts.items():
2670 master_key = str(layout["masterKey"])
2671 plans_by_layout[key] = _slot_plans(
2672 layout,
2673 prepared_layouts[key],
2674 slides_by_layout[key],
2675 prepared_slides,
2676 masters[master_key],
2677 prepared_masters[master_key],
2678 )
2679
2680 materialized_roots: list[tuple[Path, ET.Element]] = []
2681 for slide in slides:
2682 index = int(slide["index"])
2683 layout = layouts[str(slide["layoutKey"])]
2684 master = masters[str(slide["masterKey"])]
2685 filename = Path("templates") / f"{index:03d}_{_page_type(slide)}.svg"
2686 root = _compose_template(
2687 native=native,
2688 master=master,
2689 layout=layout,
2690 master_root=prepared_masters[str(master["key"])],
2691 layout_root=prepared_layouts[str(layout["key"])],
2692 slide=slide,
2693 slide_root=prepared_slides[index],
2694 slot_plans=plans_by_layout[str(layout["key"])],
2695 )
2696 materialized_roots.append((filename, root))
2697
2698 unused_layouts = [
2699 layout for key, layout in layouts.items() if not slides_by_layout[key]
2700 ]
2701 for layout in sorted(unused_layouts, key=lambda item: str(item["key"])):
2702 master = masters[str(layout["masterKey"])]
2703 filename = Path("templates") / f"layout_{layout['key']}.svg"
2704 root = _compose_template(
2705 native=native,
2706 master=master,
2707 layout=layout,
2708 master_root=prepared_masters[str(master["key"])],
2709 layout_root=prepared_layouts[str(layout["key"])],
2710 slide=None,
2711 slide_root=None,
2712 slot_plans=plans_by_layout[str(layout["key"])],
2713 )
2714 materialized_roots.append((filename, root))
2715
2716 asset_sources: dict[Path, Path] = {}
2717 files: list[MaterializedFile] = []
2718 output_roots: list[tuple[Path, ET.Element]] = []
2719 native_payloads: dict[str, bytes] = {}
2720 native_payload_stats = NativePayloadStats()
2721 for relative_path, root in materialized_roots:
2722 final_path = template_workspace / relative_path
2723 _rewrite_packaged_assets(
2724 root,
2725 source_base=authoring_root,
2726 final_svg_path=final_path,
2727 template_workspace=template_workspace,
2728 asset_sources=asset_sources,
2729 )
2730 total_stats.detached_connector_endpoints += _sanitize_connector_references(root)
2731 total_stats.upright_text_compensations += (
2732 _compensate_reflected_group_text(root)
2733 )
2734 compact_svg_tree(root, compact_native_frames=False)
2735 _refresh_preset_preview_hashes(root)
2736 try:
2737 native_payload_stats.merge(
2738 externalize_native_payloads(root, native_payloads)
2739 )
2740 except NativePayloadError as exc:
2741 raise MirrorMaterializationError(
2742 f"Cannot externalize native payloads in {relative_path}: {exc}"
2743 ) from exc
2744 output_roots.append((relative_path, root))
2745
2746 for icon in sorted(referenced_icons):
2747 record = vector_assets[icon]
2748 icon_root, stats = _materialize_icon(
2749 record,
2750 documents[record.origin_document],
2751 )
2752 total_stats.merge(stats)
2753 relative_path = Path("icons") / f"{icon}.svg"
2754 final_path = template_workspace / relative_path
2755 _rewrite_packaged_assets(
2756 icon_root,
2757 source_base=record.asset_path.parent,
2758 final_svg_path=final_path,
2759 template_workspace=template_workspace,
2760 asset_sources=asset_sources,
2761 )
2762 total_stats.detached_connector_endpoints += _sanitize_connector_references(
2763 icon_root
2764 )
2765 compact_svg_tree(icon_root, compact_native_frames=False)
2766 _refresh_preset_preview_hashes(icon_root)
2767 try:
2768 native_payload_stats.merge(
2769 externalize_native_payloads(icon_root, native_payloads)
2770 )
2771 except NativePayloadError as exc:
2772 raise MirrorMaterializationError(
2773 f"Cannot externalize native payloads in {relative_path}: {exc}"
2774 ) from exc
2775 output_roots.append((relative_path, icon_root))
2776
2777 native_record_keys: set[str] = set()
2778 try:
2779 for _relative_path, root in output_roots:
2780 native_record_keys.update(
2781 collect_native_attribute_record_keys(root)
2782 )
2783 native_record_ids, native_records = build_native_attribute_records(
2784 native_record_keys
2785 )
2786 for _relative_path, root in output_roots:
2787 native_payload_stats.merge(
2788 externalize_native_attribute_records(root, native_record_ids)
2789 )
2790 except NativePayloadError as exc:
2791 raise MirrorMaterializationError(
2792 f"Cannot externalize native attribute records: {exc}"
2793 ) from exc
2794
2795 for relative_path, root in output_roots:
2796 files.append(MaterializedFile(relative_path, _serialize_svg(root)))
2797 execution_manifest_path = Path("templates") / TEMPLATE_EXECUTION_MANIFEST_NAME
2798 files.extend(
2799 _template_execution_manifest_files(
2800 materialized_roots,
2801 _source_import_summary(import_workspace),
2802 )
2803 )
2804
2805 for relative_target, source in sorted(asset_sources.items()):
2806 files.append(MaterializedFile(relative_target, source.read_bytes()))
2807 payload_store: bytes | None = None
2808 if native_payloads or native_records:
2809 try:
2810 payload_store = serialize_native_payload_store(
2811 native_payloads,
2812 native_records,
2813 )
2814 except NativePayloadError as exc:
2815 raise MirrorMaterializationError(
2816 f"Cannot serialize native payload store: {exc}"
2817 ) from exc
2818 files.append(
2819 MaterializedFile(PAYLOAD_STORE_RELATIVE_PATH, payload_store)
2820 )
2821 relative_files = [item.relative_path for item in files]
2822 if len(relative_files) != len(set(relative_files)):
2823 raise MirrorMaterializationError("Materializer produced duplicate output paths")
2824 _preflight_output(import_workspace, template_workspace, relative_files)
2825
2826 staging_parent = _nearest_existing_directory(template_workspace.parent)
2827 with tempfile.TemporaryDirectory(
2828 prefix=".mirror-template-materialize-",
2829 dir=staging_parent,
2830 ) as temporary:
2831 staged_root = Path(temporary) / "staged"
2832 for item in files:
2833 target = staged_root / item.relative_path
2834 target.parent.mkdir(parents=True, exist_ok=True)
2835 target.write_bytes(item.payload)
2836
2837 template_paths = sorted(
2838 staged_root / relative
2839 for relative, _root in materialized_roots
2840 )
2841 try:
2842 parse_template_slides(template_paths)
2843 except TemplateStructureError as exc:
2844 raise MirrorMaterializationError(
2845 f"Materialized structured SVG contract is invalid: {exc}"
2846 ) from exc
2847 for relative in relative_files:
2848 if relative.suffix.lower() == ".svg":
2849 staged_svg = staged_root / relative
2850 try:
2851 hydrate_native_payload_refs(_parse_svg(staged_svg), staged_svg)
2852 except NativePayloadError as exc:
2853 raise MirrorMaterializationError(
2854 f"Materialized native payload reference is invalid: "
2855 f"{relative}: {exc}"
2856 ) from exc
2857 _ensure_no_source_refs(staged_svg)
2858 _publish_files(
2859 template_workspace,
2860 staged_root,
2861 sorted(relative_files, key=lambda item: item.as_posix()),
2862 )
2863
2864 return {
2865 "schema": "ppt-master.mirror-materialization-report.v1",
2866 "import_workspace": str(import_workspace),
2867 "template_workspace": str(template_workspace),
2868 "source_slide_indexes": [int(item["index"]) for item in slides],
2869 "source_slide_count": len(slides),
2870 "master_count": len(masters),
2871 "layout_count": len(layouts),
2872 "unused_layout_count": len(unused_layouts),
2873 "template_svg_count": len(materialized_roots),
2874 "template_execution_manifest": execution_manifest_path.as_posix(),
2875 "template_text_slot_manifest_count": len(materialized_roots),
2876 "imported_vector_count": len(referenced_icons),
2877 "packaged_asset_count": len(asset_sources),
2878 "restoration": total_stats.as_dict(),
2879 "native_payloads": {
2880 **native_payload_stats.as_dict(),
2881 "unique_count": len(native_payloads),
2882 "unique_record_count": len(native_records),
2883 "unique_raw_bytes": sum(len(payload) for payload in native_payloads.values()),
2884 "store_bytes": len(payload_store or b""),
2885 "store_path": (
2886 PAYLOAD_STORE_RELATIVE_PATH.as_posix()
2887 if payload_store is not None
2888 else None
2889 ),
2890 },
2891 "files": [
2892 item.relative_path.as_posix()
2893 for item in sorted(files, key=lambda item: item.relative_path.as_posix())
2894 ],
2895 }
2896
2897
2898 def build_parser() -> argparse.ArgumentParser:
2899 parser = argparse.ArgumentParser(
2900 description=(
2901 "Materialize a deterministic mirror template from one PPTX import "
2902 "workspace's layered authoring IR."
2903 )
2904 )
2905 parser.add_argument(
2906 "import_workspace",
2907 type=Path,
2908 help="Type A PPTX import workspace containing authoring-svg/ and native facts",
2909 )
2910 parser.add_argument(
2911 "template_workspace",
2912 type=Path,
2913 help="Empty template workspace destination (templates/ must be absent or empty)",
2914 )
2915 return parser
2916
2917
2918 def main(argv: list[str] | None = None) -> int:
2919 args = build_parser().parse_args(argv)
2920 import_workspace = args.import_workspace.resolve()
2921 template_workspace = args.template_workspace.resolve()
2922 if not import_workspace.is_dir():
2923 print(f"Error: import workspace does not exist: {import_workspace}", file=sys.stderr)
2924 return 1
2925 try:
2926 report = materialize_mirror_template(import_workspace, template_workspace)
2927 except (MirrorMaterializationError, OSError) as exc:
2928 print(f"Error: {exc}", file=sys.stderr)
2929 return 1
2930 print(json.dumps(report, ensure_ascii=False, indent=2))
2931 return 0
2932
2933
2934 if __name__ == "__main__":
2935 raise SystemExit(main())
2936
2936 lines PYTHON