#!/usr/bin/env python3 """ PPT Master - Native Shape Semantic Fingerprints Build stable hashes for visible SVG text, generated preset previews, and native chart/table fallback subtrees. Usage: Import the fingerprint helper for the relevant semantic carrier. Examples: digest = svg_text_fingerprint(group_element) Dependencies: None (only uses standard library) """ from __future__ import annotations import hashlib import json import re from xml.etree import ElementTree as ET _ROOT_TEXT_STYLE_ATTRS = frozenset({ "class", "fill", "fill-opacity", "font-family", "font-size", "font-style", "font-weight", "letter-spacing", "opacity", "style", "text-anchor", "text-decoration", "word-spacing", }) NATIVE_FALLBACK_SHA256_ATTR = "data-pptx-fallback-sha256" _NATIVE_FALLBACK_IGNORED_TAGS = frozenset({"metadata", "title", "desc"}) _NATIVE_FALLBACK_IGNORED_ATTRS = frozenset({ "id", "data-name", "data-ph-type", }) _URL_ID_RE = re.compile( r"url\(\s*(?P['\"]?)#(?P[^)'\"\s]+)(?P=quote)\s*\)", re.IGNORECASE, ) def svg_text_fingerprint(root: ET.Element) -> str: """Hash text content, structure, positioning, and visible typography. Shape-level movement is intentionally excluded: the native ``a:xfrm`` owns that change and the original ``p:txBody`` remains valid. Text/tspan transforms and all their non-semantic attributes remain part of the hash. """ payload = { "root_style": sorted( (name, value) for name, value in root.attrib.items() if name in _ROOT_TEXT_STYLE_ATTRS ), "text": [ _element_payload(element) for element in root.iter() if _local_name(element.tag) == "text" ], } canonical = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def svg_preset_preview_fingerprint(root: ET.Element) -> str: """Hash the complete visible preview subtree and intermediate wrappers.""" payload = _preview_subtree(root, is_root=True, active=False) canonical = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def svg_native_fallback_fingerprint( root: ET.Element, *, document_root: ET.Element | None = None, ) -> str: """Hash one native chart/table marker's rendering-relevant SVG subtree. Native metadata, editor/runtime attributes, and stable element IDs are not fallback artwork. Reachable document-level fragment definitions are hashed when ``document_root`` is available. Transforms remain part of the digest because complete explicit native bounds are absolute and therefore do not consume marker transforms; changing one must make the replacement stale. """ id_tokens = _native_fallback_id_tokens(root) dependencies = _native_fallback_external_dependencies( root, document_root, id_tokens, ) payload = _native_fallback_subtree( root, id_tokens=id_tokens, ) if dependencies: payload = { "marker": payload, "external_dependencies": [ { "token": id_tokens[element_id], "node": _native_fallback_subtree( target, id_tokens=id_tokens, force_include=True, ), } for element_id, target in dependencies ], } canonical = json.dumps( payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(canonical).hexdigest() def svg_native_fallback_markup_fingerprint( markup: str, *, root_transform: str | None = None, external_markup: str | None = None, ) -> str: """Hash an SVG fallback fragment through the canonical marker function.""" if external_markup: document_root = ET.fromstring( '' f"{external_markup}{markup}" "" ) wrapper = document_root[-1] else: document_root = None wrapper = ET.fromstring( '' f"{markup}" "" ) if root_transform: wrapper.set("transform", root_transform) return svg_native_fallback_fingerprint( wrapper, document_root=document_root, ) def resolve_preset_preview_hash(root: ET.Element) -> str | None: """Resolve and cross-check a logical preset group's fingerprint contract. The hash is duplicated on the logical group and hidden native carrier so stripping either copy cannot disable stale-preview detection. A visible generated preview without either hash is invalid rather than legacy SVG. """ has_preview = any( element.get("data-pptx-part") in {"geometry-preview", "geometry-detail"} for element in root.iter() ) carrier_hashes = { value for element in root.iter() if element.get("data-pptx-part") == "geometry" and (value := element.get("data-pptx-preview-sha256")) is not None } group_hash = root.get("data-pptx-preview-sha256") if not has_preview and not carrier_hashes and group_hash is None: return None if len(carrier_hashes) > 1: raise ValueError("Native geometry carriers have inconsistent preview hashes") carrier_hash = next(iter(carrier_hashes), None) if ( group_hash is not None and carrier_hash is not None and group_hash != carrier_hash ): raise ValueError("Logical group and native carrier preview hashes differ") expected = group_hash or carrier_hash if expected is None: raise ValueError("Generated preset preview is missing its fingerprint") return expected def _preview_subtree( element: ET.Element, *, is_root: bool, active: bool, ) -> dict | None: part = element.get("data-pptx-part") contains_preview = part in {"geometry-preview", "geometry-detail"} child_active = active or contains_preview children = [ payload for child in element if ( payload := _preview_subtree( child, is_root=False, active=child_active, ) ) is not None ] if is_root: return {"children": children} if not child_active and not children: return None return { "tag": _local_name(element.tag), "attrs": sorted( (name, value) for name, value in element.attrib.items() if name != "id" and name != "data-pptx-preview-sha256" and not name.startswith("data-pptx-runtime-") ), "children": children, } def _native_fallback_subtree( element: ET.Element, *, id_tokens: dict[str, str], force_include: bool = False, ) -> dict | None: tag = _local_name(element.tag) if not force_include and _native_fallback_element_hidden(element): return None attrs = [] for raw_name, raw_value in element.attrib.items(): name = _local_name(raw_name) if name in _NATIVE_FALLBACK_IGNORED_ATTRS: continue if name.startswith("data-pptx-"): continue attrs.append(( raw_name, _normalize_native_fallback_id_refs(name, raw_value, id_tokens), )) children = [] for child in element: child_payload = _native_fallback_subtree( child, id_tokens=id_tokens, ) if child_payload is None: continue entry = {"node": child_payload} if child.tail and ( child.tail.strip() or tag in {"text", "tspan", "textPath"} ): entry["tail"] = child.tail children.append(entry) text = element.text or "" payload = { "tag": tag, "attrs": sorted(attrs), "children": children, } if text and (text.strip() or tag in {"text", "tspan", "textPath", "style"}): payload["text"] = text return payload def _native_fallback_id_tokens( root: ET.Element, ) -> dict[str, str]: tokens: dict[str, str] = {} def visit( element: ET.Element, *, is_root: bool, path: tuple[int, ...], ) -> None: element_id = None if is_root else element.get("id") if element_id and element_id not in tokens: tokens[element_id] = "native-node-" + "-".join(map(str, path)) canonical_index = 0 for child in element: if _native_fallback_element_hidden(child): continue visit( child, is_root=False, path=(*path, canonical_index), ) canonical_index += 1 visit(root, is_root=True, path=()) return tokens def _native_fallback_external_dependencies( marker: ET.Element, document_root: ET.Element | None, id_tokens: dict[str, str], ) -> list[tuple[str, ET.Element]]: """Resolve the marker's reachable document-level fragment references.""" if document_root is None or document_root is marker: return [] marker_nodes = set(marker.iter()) targets: dict[str, ET.Element] = {} for element in document_root.iter(): element_id = element.get("id") if element_id and element_id not in targets: targets[element_id] = element dependencies: list[tuple[str, ET.Element]] = [] def add_reference(element_id: str) -> None: if not element_id or element_id in id_tokens: return target = targets.get(element_id) if target is None or target in marker_nodes: return id_tokens[element_id] = f"native-external-{len(dependencies) + 1}" dependencies.append((element_id, target)) for nested_id in _native_fallback_fragment_references( target, force_include_root=True, ): add_reference(nested_id) for element_id in _native_fallback_fragment_references(marker): add_reference(element_id) return dependencies def _native_fallback_fragment_references( root: ET.Element, *, force_include_root: bool = False, ) -> list[str]: references: list[str] = [] seen: set[str] = set() def add(element_id: str) -> None: if element_id and element_id not in seen: seen.add(element_id) references.append(element_id) def visit(element: ET.Element, *, force_include: bool) -> None: if not force_include and _native_fallback_element_hidden(element): return for raw_name, raw_value in sorted(element.attrib.items()): name = _local_name(raw_name) if name in _NATIVE_FALLBACK_IGNORED_ATTRS: continue if name.startswith("data-pptx-"): continue for match in _URL_ID_RE.finditer(raw_value): add(match.group("id")) if name == "href" and raw_value.startswith("#"): add(raw_value[1:]) for child in element: visit(child, force_include=False) visit(root, force_include=force_include_root) return references def _native_fallback_element_hidden(element: ET.Element) -> bool: if _local_name(element.tag) in _NATIVE_FALLBACK_IGNORED_TAGS: return True # ``display:none`` suppresses the entire descendant subtree. SVG # ``visibility`` is different: a descendant may explicitly restore # ``visibility:visible``. Keep visibility-hidden content in the digest # conservatively so such visible descendants cannot evade stale detection. return _native_fallback_style_value(element, "display") == "none" def _normalize_native_fallback_id_refs( name: str, value: str, id_tokens: dict[str, str], ) -> str: def replace_url(match: re.Match[str]) -> str: token = id_tokens.get(match.group("id")) return f"url(#{token})" if token is not None else match.group(0) normalized = _URL_ID_RE.sub(replace_url, value) if name in {"href", "xlink:href"} and normalized.startswith("#"): token = id_tokens.get(normalized[1:]) if token is not None: return f"#{token}" return normalized def _native_fallback_style_value(element: ET.Element, name: str) -> str | None: raw = element.get(name) if raw is not None: return raw.strip().lower() style = element.get("style") or "" for declaration in style.split(";"): if ":" not in declaration: continue key, value = declaration.split(":", 1) if key.strip().lower() == name: return value.strip().lower() return None def _element_payload(element: ET.Element) -> dict: return { "tag": _local_name(element.tag), "attrs": sorted( (name, value) for name, value in element.attrib.items() if not name.startswith("data-pptx-") and name != "id" ), "text": element.text or "", "children": [ { "node": _element_payload(child), "tail": child.tail or "", } for child in element if _local_name(child.tag) in {"text", "tspan"} ], } def _local_name(tag: str) -> str: return tag.rsplit("}", 1)[-1]