Files

457 lines
14 KiB
Python

#!/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<quote>['\"]?)#(?P<id>[^)'\"\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(
'<svg xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
f"<defs>{external_markup}</defs><g>{markup}</g>"
"</svg>"
)
wrapper = document_root[-1]
else:
document_root = None
wrapper = ET.fromstring(
'<g xmlns="http://www.w3.org/2000/svg" '
'xmlns:xlink="http://www.w3.org/1999/xlink">'
f"{markup}"
"</g>"
)
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]