Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user