返回 ppt-master
template_text_slots.py
根目录 / skills / ppt-master / scripts / template_text_slots.py
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
176 lines PYTHON