457 lines
14 KiB
Python
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]
|