| 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 |