176 lines
5.2 KiB
Python
176 lines
5.2 KiB
Python
"""Shared projection and integrity helpers for mirror-template text slots."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
MODEL_TEXT_SLOT_KEYS = (
|
|
"selector",
|
|
"role",
|
|
"current_text",
|
|
"text_segments",
|
|
"tspan_count",
|
|
)
|
|
|
|
|
|
def _local_name(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def _ancestor_chain(
|
|
element: ET.Element,
|
|
parent_by_child: dict[ET.Element, ET.Element],
|
|
) -> list[ET.Element]:
|
|
chain = [element]
|
|
while chain[-1] in parent_by_child:
|
|
chain.append(parent_by_child[chain[-1]])
|
|
return chain
|
|
|
|
|
|
def _nearest_attribute(chain: list[ET.Element], name: str) -> str | None:
|
|
for element in chain:
|
|
value = element.get(name)
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _text_selector(
|
|
element: ET.Element,
|
|
parent_by_child: dict[ET.Element, ET.Element],
|
|
*,
|
|
compact: bool,
|
|
) -> str:
|
|
segments: list[str] = []
|
|
current = element
|
|
while True:
|
|
element_id = (current.get("id") or "").strip()
|
|
if element_id:
|
|
segments.append(f"#{element_id}")
|
|
break
|
|
parent = parent_by_child.get(current)
|
|
tag = _local_name(current.tag)
|
|
if parent is None:
|
|
segments.append(tag)
|
|
break
|
|
same_tag = [child for child in parent if _local_name(child.tag) == tag]
|
|
if compact and len(same_tag) == 1:
|
|
segments.append(tag)
|
|
else:
|
|
segments.append(f"{tag}:nth-of-type({same_tag.index(current) + 1})")
|
|
current = parent
|
|
separator = ">" if compact else " > "
|
|
return separator.join(reversed(segments))
|
|
|
|
|
|
def _text_topology_sha256(element: ET.Element) -> str:
|
|
"""Hash text/tspan topology and attributes while excluding visible values."""
|
|
digest = hashlib.sha256()
|
|
|
|
def visit(node: ET.Element) -> None:
|
|
digest.update(_local_name(node.tag).encode("utf-8"))
|
|
for name, value in sorted(node.attrib.items()):
|
|
digest.update(b"\0a")
|
|
digest.update(name.encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(value.encode("utf-8"))
|
|
for child in node:
|
|
digest.update(b"\0c")
|
|
visit(child)
|
|
digest.update(b"\0e")
|
|
|
|
visit(element)
|
|
return digest.hexdigest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TemplateTextSlot:
|
|
selector: str
|
|
legacy_selector: str
|
|
role: str
|
|
current_text: str
|
|
text_segments: tuple[str, ...]
|
|
tspan_count: int
|
|
topology_sha256: str
|
|
editable: bool
|
|
|
|
def model_payload(self) -> dict[str, object]:
|
|
return {
|
|
"selector": self.selector,
|
|
"role": self.role,
|
|
"current_text": self.current_text,
|
|
"text_segments": list(self.text_segments),
|
|
"tspan_count": self.tspan_count,
|
|
}
|
|
|
|
|
|
def analyze_template_text_slots(root: ET.Element) -> tuple[TemplateTextSlot, ...]:
|
|
"""Derive the model projection and tool-only integrity facts from one SVG."""
|
|
parent_by_child = {
|
|
child: parent
|
|
for parent in root.iter()
|
|
for child in parent
|
|
}
|
|
slots: list[TemplateTextSlot] = []
|
|
for text_element in root.iter():
|
|
if _local_name(text_element.tag) != "text":
|
|
continue
|
|
chain = _ancestor_chain(text_element, parent_by_child)
|
|
placeholder = _nearest_attribute(chain, "data-pptx-placeholder")
|
|
editable_value = _nearest_attribute(chain, "data-pptx-editable")
|
|
inherited_layer = _nearest_attribute(chain, "data-pptx-layer")
|
|
tspans = [
|
|
child
|
|
for child in text_element.iter()
|
|
if child is not text_element and _local_name(child.tag) == "tspan"
|
|
]
|
|
segments = [
|
|
text_element.text or "",
|
|
*[(tspan.text or "") for tspan in tspans],
|
|
]
|
|
if tspans and not segments[0].strip():
|
|
segments = segments[1:]
|
|
slots.append(TemplateTextSlot(
|
|
selector=_text_selector(
|
|
text_element,
|
|
parent_by_child,
|
|
compact=True,
|
|
),
|
|
legacy_selector=_text_selector(
|
|
text_element,
|
|
parent_by_child,
|
|
compact=False,
|
|
),
|
|
role=placeholder or "text",
|
|
current_text="".join(text_element.itertext()),
|
|
text_segments=tuple(segments),
|
|
tspan_count=len(tspans),
|
|
topology_sha256=_text_topology_sha256(text_element),
|
|
editable=editable_value != "false" and inherited_layer is None,
|
|
))
|
|
selectors = [slot.selector for slot in slots]
|
|
if len(selectors) != len(set(selectors)):
|
|
raise ValueError("template text selectors are not unique")
|
|
return tuple(slots)
|
|
|
|
|
|
def text_slot_integrity_sha256(slots: tuple[TemplateTextSlot, ...]) -> str:
|
|
"""Hash selectors plus immutable text/tspan topology and attributes."""
|
|
payload = [
|
|
{
|
|
"selector": slot.selector,
|
|
"topology_sha256": slot.topology_sha256,
|
|
}
|
|
for slot in slots
|
|
]
|
|
serialized = json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
return hashlib.sha256(serialized).hexdigest()
|