| 1 | """Shared projection and integrity helpers for mirror-template text slots.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import hashlib |
| 6 | import json |
| 7 | from dataclasses import dataclass |
| 8 | from xml.etree import ElementTree as ET |
| 9 | |
| 10 | |
| 11 | MODEL_TEXT_SLOT_KEYS = ( |
| 12 | "selector", |
| 13 | "role", |
| 14 | "current_text", |
| 15 | "text_segments", |
| 16 | "tspan_count", |
| 17 | ) |
| 18 | |
| 19 | |
| 20 | def _local_name(tag: str) -> str: |
| 21 | return tag.rsplit("}", 1)[-1] |
| 22 | |
| 23 | |
| 24 | def _ancestor_chain( |
| 25 | element: ET.Element, |
| 26 | parent_by_child: dict[ET.Element, ET.Element], |
| 27 | ) -> list[ET.Element]: |
| 28 | chain = [element] |
| 29 | while chain[-1] in parent_by_child: |
| 30 | chain.append(parent_by_child[chain[-1]]) |
| 31 | return chain |
| 32 | |
| 33 | |
| 34 | def _nearest_attribute(chain: list[ET.Element], name: str) -> str | None: |
| 35 | for element in chain: |
| 36 | value = element.get(name) |
| 37 | if value is not None: |
| 38 | return value |
| 39 | return None |
| 40 | |
| 41 | |
| 42 | def _text_selector( |
| 43 | element: ET.Element, |
| 44 | parent_by_child: dict[ET.Element, ET.Element], |
| 45 | *, |
| 46 | compact: bool, |
| 47 | ) -> str: |
| 48 | segments: list[str] = [] |
| 49 | current = element |
| 50 | while True: |
| 51 | element_id = (current.get("id") or "").strip() |
| 52 | if element_id: |
| 53 | segments.append(f"#{element_id}") |
| 54 | break |
| 55 | parent = parent_by_child.get(current) |
| 56 | tag = _local_name(current.tag) |
| 57 | if parent is None: |
| 58 | segments.append(tag) |
| 59 | break |
| 60 | same_tag = [child for child in parent if _local_name(child.tag) == tag] |
| 61 | if compact and len(same_tag) == 1: |
| 62 | segments.append(tag) |
| 63 | else: |
| 64 | segments.append(f"{tag}:nth-of-type({same_tag.index(current) + 1})") |
| 65 | current = parent |
| 66 | separator = ">" if compact else " > " |
| 67 | return separator.join(reversed(segments)) |
| 68 | |
| 69 | |
| 70 | def _text_topology_sha256(element: ET.Element) -> str: |
| 71 | """Hash text/tspan topology and attributes while excluding visible values.""" |
| 72 | digest = hashlib.sha256() |
| 73 | |
| 74 | def visit(node: ET.Element) -> None: |
| 75 | digest.update(_local_name(node.tag).encode("utf-8")) |
| 76 | for name, value in sorted(node.attrib.items()): |
| 77 | digest.update(b"\0a") |
| 78 | digest.update(name.encode("utf-8")) |
| 79 | digest.update(b"\0") |
| 80 | digest.update(value.encode("utf-8")) |
| 81 | for child in node: |
| 82 | digest.update(b"\0c") |
| 83 | visit(child) |
| 84 | digest.update(b"\0e") |
| 85 | |
| 86 | visit(element) |
| 87 | return digest.hexdigest() |
| 88 | |
| 89 | |
| 90 | @dataclass(frozen=True) |
| 91 | class TemplateTextSlot: |
| 92 | selector: str |
| 93 | legacy_selector: str |
| 94 | role: str |
| 95 | current_text: str |
| 96 | text_segments: tuple[str, ...] |
| 97 | tspan_count: int |
| 98 | topology_sha256: str |
| 99 | editable: bool |
| 100 | |
| 101 | def model_payload(self) -> dict[str, object]: |
| 102 | return { |
| 103 | "selector": self.selector, |
| 104 | "role": self.role, |
| 105 | "current_text": self.current_text, |
| 106 | "text_segments": list(self.text_segments), |
| 107 | "tspan_count": self.tspan_count, |
| 108 | } |
| 109 | |
| 110 | |
| 111 | def analyze_template_text_slots(root: ET.Element) -> tuple[TemplateTextSlot, ...]: |
| 112 | """Derive the model projection and tool-only integrity facts from one SVG.""" |
| 113 | parent_by_child = { |
| 114 | child: parent |
| 115 | for parent in root.iter() |
| 116 | for child in parent |
| 117 | } |
| 118 | slots: list[TemplateTextSlot] = [] |
| 119 | for text_element in root.iter(): |
| 120 | if _local_name(text_element.tag) != "text": |
| 121 | continue |
| 122 | chain = _ancestor_chain(text_element, parent_by_child) |
| 123 | placeholder = _nearest_attribute(chain, "data-pptx-placeholder") |
| 124 | editable_value = _nearest_attribute(chain, "data-pptx-editable") |
| 125 | inherited_layer = _nearest_attribute(chain, "data-pptx-layer") |
| 126 | tspans = [ |
| 127 | child |
| 128 | for child in text_element.iter() |
| 129 | if child is not text_element and _local_name(child.tag) == "tspan" |
| 130 | ] |
| 131 | segments = [ |
| 132 | text_element.text or "", |
| 133 | *[(tspan.text or "") for tspan in tspans], |
| 134 | ] |
| 135 | if tspans and not segments[0].strip(): |
| 136 | segments = segments[1:] |
| 137 | slots.append(TemplateTextSlot( |
| 138 | selector=_text_selector( |
| 139 | text_element, |
| 140 | parent_by_child, |
| 141 | compact=True, |
| 142 | ), |
| 143 | legacy_selector=_text_selector( |
| 144 | text_element, |
| 145 | parent_by_child, |
| 146 | compact=False, |
| 147 | ), |
| 148 | role=placeholder or "text", |
| 149 | current_text="".join(text_element.itertext()), |
| 150 | text_segments=tuple(segments), |
| 151 | tspan_count=len(tspans), |
| 152 | topology_sha256=_text_topology_sha256(text_element), |
| 153 | editable=editable_value != "false" and inherited_layer is None, |
| 154 | )) |
| 155 | selectors = [slot.selector for slot in slots] |
| 156 | if len(selectors) != len(set(selectors)): |
| 157 | raise ValueError("template text selectors are not unique") |
| 158 | return tuple(slots) |
| 159 | |
| 160 | |
| 161 | def text_slot_integrity_sha256(slots: tuple[TemplateTextSlot, ...]) -> str: |
| 162 | """Hash selectors plus immutable text/tspan topology and attributes.""" |
| 163 | payload = [ |
| 164 | { |
| 165 | "selector": slot.selector, |
| 166 | "topology_sha256": slot.topology_sha256, |
| 167 | } |
| 168 | for slot in slots |
| 169 | ] |
| 170 | serialized = json.dumps( |
| 171 | payload, |
| 172 | ensure_ascii=False, |
| 173 | separators=(",", ":"), |
| 174 | ).encode("utf-8") |
| 175 | return hashlib.sha256(serialized).hexdigest() |
| 176 |