返回 ppt-master
semantic_hash.py
根目录 / skills / ppt-master / scripts / pptx_shapes / semantic_hash.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Native Shape Semantic Fingerprints
4
5 Build stable hashes for visible SVG text, generated preset previews, and
6 native chart/table fallback subtrees.
7
8 Usage:
9 Import the fingerprint helper for the relevant semantic carrier.
10
11 Examples:
12 digest = svg_text_fingerprint(group_element)
13
14 Dependencies:
15 None (only uses standard library)
16 """
17
18 from __future__ import annotations
19
20 import hashlib
21 import json
22 import re
23 from xml.etree import ElementTree as ET
24
25
26 _ROOT_TEXT_STYLE_ATTRS = frozenset({
27 "class",
28 "fill",
29 "fill-opacity",
30 "font-family",
31 "font-size",
32 "font-style",
33 "font-weight",
34 "letter-spacing",
35 "opacity",
36 "style",
37 "text-anchor",
38 "text-decoration",
39 "word-spacing",
40 })
41
42 NATIVE_FALLBACK_SHA256_ATTR = "data-pptx-fallback-sha256"
43 _NATIVE_FALLBACK_IGNORED_TAGS = frozenset({"metadata", "title", "desc"})
44 _NATIVE_FALLBACK_IGNORED_ATTRS = frozenset({
45 "id",
46 "data-name",
47 "data-ph-type",
48 })
49 _URL_ID_RE = re.compile(
50 r"url\(\s*(?P<quote>['\"]?)#(?P<id>[^)'\"\s]+)(?P=quote)\s*\)",
51 re.IGNORECASE,
52 )
53
54
55 def svg_text_fingerprint(root: ET.Element) -> str:
56 """Hash text content, structure, positioning, and visible typography.
57
58 Shape-level movement is intentionally excluded: the native ``a:xfrm``
59 owns that change and the original ``p:txBody`` remains valid. Text/tspan
60 transforms and all their non-semantic attributes remain part of the hash.
61 """
62
63 payload = {
64 "root_style": sorted(
65 (name, value)
66 for name, value in root.attrib.items()
67 if name in _ROOT_TEXT_STYLE_ATTRS
68 ),
69 "text": [
70 _element_payload(element)
71 for element in root.iter()
72 if _local_name(element.tag) == "text"
73 ],
74 }
75 canonical = json.dumps(
76 payload,
77 ensure_ascii=False,
78 sort_keys=True,
79 separators=(",", ":"),
80 ).encode("utf-8")
81 return hashlib.sha256(canonical).hexdigest()
82
83
84 def svg_preset_preview_fingerprint(root: ET.Element) -> str:
85 """Hash the complete visible preview subtree and intermediate wrappers."""
86 payload = _preview_subtree(root, is_root=True, active=False)
87 canonical = json.dumps(
88 payload,
89 ensure_ascii=False,
90 sort_keys=True,
91 separators=(",", ":"),
92 ).encode("utf-8")
93 return hashlib.sha256(canonical).hexdigest()
94
95
96 def svg_native_fallback_fingerprint(
97 root: ET.Element,
98 *,
99 document_root: ET.Element | None = None,
100 ) -> str:
101 """Hash one native chart/table marker's rendering-relevant SVG subtree.
102
103 Native metadata, editor/runtime attributes, and stable element IDs are not
104 fallback artwork. Reachable document-level fragment definitions are hashed
105 when ``document_root`` is available. Transforms remain part of the digest
106 because complete explicit native bounds are absolute and therefore do not
107 consume marker transforms; changing one must make the replacement stale.
108 """
109 id_tokens = _native_fallback_id_tokens(root)
110 dependencies = _native_fallback_external_dependencies(
111 root,
112 document_root,
113 id_tokens,
114 )
115 payload = _native_fallback_subtree(
116 root,
117 id_tokens=id_tokens,
118 )
119 if dependencies:
120 payload = {
121 "marker": payload,
122 "external_dependencies": [
123 {
124 "token": id_tokens[element_id],
125 "node": _native_fallback_subtree(
126 target,
127 id_tokens=id_tokens,
128 force_include=True,
129 ),
130 }
131 for element_id, target in dependencies
132 ],
133 }
134 canonical = json.dumps(
135 payload,
136 ensure_ascii=False,
137 sort_keys=True,
138 separators=(",", ":"),
139 ).encode("utf-8")
140 return hashlib.sha256(canonical).hexdigest()
141
142
143 def svg_native_fallback_markup_fingerprint(
144 markup: str,
145 *,
146 root_transform: str | None = None,
147 external_markup: str | None = None,
148 ) -> str:
149 """Hash an SVG fallback fragment through the canonical marker function."""
150 if external_markup:
151 document_root = ET.fromstring(
152 '<svg xmlns="http://www.w3.org/2000/svg" '
153 'xmlns:xlink="http://www.w3.org/1999/xlink">'
154 f"<defs>{external_markup}</defs><g>{markup}</g>"
155 "</svg>"
156 )
157 wrapper = document_root[-1]
158 else:
159 document_root = None
160 wrapper = ET.fromstring(
161 '<g xmlns="http://www.w3.org/2000/svg" '
162 'xmlns:xlink="http://www.w3.org/1999/xlink">'
163 f"{markup}"
164 "</g>"
165 )
166 if root_transform:
167 wrapper.set("transform", root_transform)
168 return svg_native_fallback_fingerprint(
169 wrapper,
170 document_root=document_root,
171 )
172
173
174 def resolve_preset_preview_hash(root: ET.Element) -> str | None:
175 """Resolve and cross-check a logical preset group's fingerprint contract.
176
177 The hash is duplicated on the logical group and hidden native carrier so
178 stripping either copy cannot disable stale-preview detection. A visible
179 generated preview without either hash is invalid rather than legacy SVG.
180 """
181 has_preview = any(
182 element.get("data-pptx-part")
183 in {"geometry-preview", "geometry-detail"}
184 for element in root.iter()
185 )
186 carrier_hashes = {
187 value
188 for element in root.iter()
189 if element.get("data-pptx-part") == "geometry"
190 and (value := element.get("data-pptx-preview-sha256")) is not None
191 }
192 group_hash = root.get("data-pptx-preview-sha256")
193 if not has_preview and not carrier_hashes and group_hash is None:
194 return None
195 if len(carrier_hashes) > 1:
196 raise ValueError("Native geometry carriers have inconsistent preview hashes")
197 carrier_hash = next(iter(carrier_hashes), None)
198 if (
199 group_hash is not None
200 and carrier_hash is not None
201 and group_hash != carrier_hash
202 ):
203 raise ValueError("Logical group and native carrier preview hashes differ")
204 expected = group_hash or carrier_hash
205 if expected is None:
206 raise ValueError("Generated preset preview is missing its fingerprint")
207 return expected
208
209
210 def _preview_subtree(
211 element: ET.Element,
212 *,
213 is_root: bool,
214 active: bool,
215 ) -> dict | None:
216 part = element.get("data-pptx-part")
217 contains_preview = part in {"geometry-preview", "geometry-detail"}
218 child_active = active or contains_preview
219 children = [
220 payload
221 for child in element
222 if (
223 payload := _preview_subtree(
224 child,
225 is_root=False,
226 active=child_active,
227 )
228 ) is not None
229 ]
230 if is_root:
231 return {"children": children}
232 if not child_active and not children:
233 return None
234 return {
235 "tag": _local_name(element.tag),
236 "attrs": sorted(
237 (name, value)
238 for name, value in element.attrib.items()
239 if name != "id"
240 and name != "data-pptx-preview-sha256"
241 and not name.startswith("data-pptx-runtime-")
242 ),
243 "children": children,
244 }
245
246
247 def _native_fallback_subtree(
248 element: ET.Element,
249 *,
250 id_tokens: dict[str, str],
251 force_include: bool = False,
252 ) -> dict | None:
253 tag = _local_name(element.tag)
254 if not force_include and _native_fallback_element_hidden(element):
255 return None
256
257 attrs = []
258 for raw_name, raw_value in element.attrib.items():
259 name = _local_name(raw_name)
260 if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
261 continue
262 if name.startswith("data-pptx-"):
263 continue
264 attrs.append((
265 raw_name,
266 _normalize_native_fallback_id_refs(name, raw_value, id_tokens),
267 ))
268
269 children = []
270 for child in element:
271 child_payload = _native_fallback_subtree(
272 child,
273 id_tokens=id_tokens,
274 )
275 if child_payload is None:
276 continue
277 entry = {"node": child_payload}
278 if child.tail and (
279 child.tail.strip() or tag in {"text", "tspan", "textPath"}
280 ):
281 entry["tail"] = child.tail
282 children.append(entry)
283
284 text = element.text or ""
285 payload = {
286 "tag": tag,
287 "attrs": sorted(attrs),
288 "children": children,
289 }
290 if text and (text.strip() or tag in {"text", "tspan", "textPath", "style"}):
291 payload["text"] = text
292 return payload
293
294
295 def _native_fallback_id_tokens(
296 root: ET.Element,
297 ) -> dict[str, str]:
298 tokens: dict[str, str] = {}
299
300 def visit(
301 element: ET.Element,
302 *,
303 is_root: bool,
304 path: tuple[int, ...],
305 ) -> None:
306 element_id = None if is_root else element.get("id")
307 if element_id and element_id not in tokens:
308 tokens[element_id] = "native-node-" + "-".join(map(str, path))
309 canonical_index = 0
310 for child in element:
311 if _native_fallback_element_hidden(child):
312 continue
313 visit(
314 child,
315 is_root=False,
316 path=(*path, canonical_index),
317 )
318 canonical_index += 1
319
320 visit(root, is_root=True, path=())
321 return tokens
322
323
324 def _native_fallback_external_dependencies(
325 marker: ET.Element,
326 document_root: ET.Element | None,
327 id_tokens: dict[str, str],
328 ) -> list[tuple[str, ET.Element]]:
329 """Resolve the marker's reachable document-level fragment references."""
330 if document_root is None or document_root is marker:
331 return []
332
333 marker_nodes = set(marker.iter())
334 targets: dict[str, ET.Element] = {}
335 for element in document_root.iter():
336 element_id = element.get("id")
337 if element_id and element_id not in targets:
338 targets[element_id] = element
339
340 dependencies: list[tuple[str, ET.Element]] = []
341
342 def add_reference(element_id: str) -> None:
343 if not element_id or element_id in id_tokens:
344 return
345 target = targets.get(element_id)
346 if target is None or target in marker_nodes:
347 return
348 id_tokens[element_id] = f"native-external-{len(dependencies) + 1}"
349 dependencies.append((element_id, target))
350 for nested_id in _native_fallback_fragment_references(
351 target,
352 force_include_root=True,
353 ):
354 add_reference(nested_id)
355
356 for element_id in _native_fallback_fragment_references(marker):
357 add_reference(element_id)
358 return dependencies
359
360
361 def _native_fallback_fragment_references(
362 root: ET.Element,
363 *,
364 force_include_root: bool = False,
365 ) -> list[str]:
366 references: list[str] = []
367 seen: set[str] = set()
368
369 def add(element_id: str) -> None:
370 if element_id and element_id not in seen:
371 seen.add(element_id)
372 references.append(element_id)
373
374 def visit(element: ET.Element, *, force_include: bool) -> None:
375 if not force_include and _native_fallback_element_hidden(element):
376 return
377 for raw_name, raw_value in sorted(element.attrib.items()):
378 name = _local_name(raw_name)
379 if name in _NATIVE_FALLBACK_IGNORED_ATTRS:
380 continue
381 if name.startswith("data-pptx-"):
382 continue
383 for match in _URL_ID_RE.finditer(raw_value):
384 add(match.group("id"))
385 if name == "href" and raw_value.startswith("#"):
386 add(raw_value[1:])
387 for child in element:
388 visit(child, force_include=False)
389
390 visit(root, force_include=force_include_root)
391 return references
392
393
394 def _native_fallback_element_hidden(element: ET.Element) -> bool:
395 if _local_name(element.tag) in _NATIVE_FALLBACK_IGNORED_TAGS:
396 return True
397 # ``display:none`` suppresses the entire descendant subtree. SVG
398 # ``visibility`` is different: a descendant may explicitly restore
399 # ``visibility:visible``. Keep visibility-hidden content in the digest
400 # conservatively so such visible descendants cannot evade stale detection.
401 return _native_fallback_style_value(element, "display") == "none"
402
403
404 def _normalize_native_fallback_id_refs(
405 name: str,
406 value: str,
407 id_tokens: dict[str, str],
408 ) -> str:
409 def replace_url(match: re.Match[str]) -> str:
410 token = id_tokens.get(match.group("id"))
411 return f"url(#{token})" if token is not None else match.group(0)
412
413 normalized = _URL_ID_RE.sub(replace_url, value)
414 if name in {"href", "xlink:href"} and normalized.startswith("#"):
415 token = id_tokens.get(normalized[1:])
416 if token is not None:
417 return f"#{token}"
418 return normalized
419
420
421 def _native_fallback_style_value(element: ET.Element, name: str) -> str | None:
422 raw = element.get(name)
423 if raw is not None:
424 return raw.strip().lower()
425 style = element.get("style") or ""
426 for declaration in style.split(";"):
427 if ":" not in declaration:
428 continue
429 key, value = declaration.split(":", 1)
430 if key.strip().lower() == name:
431 return value.strip().lower()
432 return None
433
434
435 def _element_payload(element: ET.Element) -> dict:
436 return {
437 "tag": _local_name(element.tag),
438 "attrs": sorted(
439 (name, value)
440 for name, value in element.attrib.items()
441 if not name.startswith("data-pptx-") and name != "id"
442 ),
443 "text": element.text or "",
444 "children": [
445 {
446 "node": _element_payload(child),
447 "tail": child.tail or "",
448 }
449 for child in element
450 if _local_name(child.tag) in {"text", "tspan"}
451 ],
452 }
453
454
455 def _local_name(tag: str) -> str:
456 return tag.rsplit("}", 1)[-1]
457
457 lines PYTHON