Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PPT Master - SVG Annotation Utilities
|
||||
|
||||
Read, write, and manage edit annotations in SVG files.
|
||||
Annotations are stored as custom XML attributes (data-edit-target, data-edit-annotation)
|
||||
on SVG elements, enabling AI-driven targeted editing.
|
||||
|
||||
Usage:
|
||||
(library module — imported by server.py and check_annotations.py)
|
||||
|
||||
Dependencies:
|
||||
None (only uses standard library)
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import Optional
|
||||
|
||||
SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
XLINK_NS = 'http://www.w3.org/1999/xlink'
|
||||
|
||||
# Register namespaces to avoid generated ns0/ns1 prefixes in saved SVGs.
|
||||
ET.register_namespace('', SVG_NS)
|
||||
ET.register_namespace('xlink', XLINK_NS)
|
||||
|
||||
|
||||
def assign_temp_ids(root: ET.Element) -> None:
|
||||
"""Assign deterministic temp ids (_edit_0, _edit_1, ...) to elements without one.
|
||||
|
||||
Clears any leftover _edit_N ids from previous sessions first, to avoid
|
||||
shifted numbering when elements are added/removed between sessions.
|
||||
"""
|
||||
for elem in root.iter():
|
||||
eid = elem.get('id', '')
|
||||
if eid.startswith('_edit_'):
|
||||
elem.attrib.pop('id', None)
|
||||
|
||||
counter = 0
|
||||
for elem in root.iter():
|
||||
if elem is root:
|
||||
continue
|
||||
if elem.get('id') is None:
|
||||
elem.set('id', f'_edit_{counter}')
|
||||
counter += 1
|
||||
|
||||
|
||||
def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
|
||||
"""Find an element by its id attribute in the SVG tree."""
|
||||
for elem in root.iter():
|
||||
if elem.get('id') == element_id:
|
||||
return elem
|
||||
return None
|
||||
|
||||
|
||||
def _find_with_parent(
|
||||
root: ET.Element, element_id: str,
|
||||
) -> tuple[Optional[ET.Element], Optional[ET.Element]]:
|
||||
"""Find an element and its parent by id."""
|
||||
for parent in root.iter():
|
||||
for child in list(parent):
|
||||
if child.get('id') == element_id:
|
||||
return child, parent
|
||||
return None, None
|
||||
|
||||
|
||||
def _local_name(elem: ET.Element) -> str:
|
||||
return elem.tag.split('}', 1)[1] if '}' in elem.tag else elem.tag
|
||||
|
||||
|
||||
def _first_number(value: Optional[str]) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
match = re.search(r'-?\d+(?:\.\d+)?', value)
|
||||
return float(match.group(0)) if match else None
|
||||
|
||||
|
||||
def _format_number(value: float) -> str:
|
||||
text = f'{value:.3f}'.rstrip('0').rstrip('.')
|
||||
return text or '0'
|
||||
|
||||
|
||||
def _tspan_baseline(text_el: ET.Element, tspan_el: ET.Element) -> Optional[tuple[float, float]]:
|
||||
cur_x = _first_number(text_el.get('x'))
|
||||
cur_y = _first_number(text_el.get('y'))
|
||||
for child in list(text_el):
|
||||
if _local_name(child) != 'tspan':
|
||||
continue
|
||||
x_val = _first_number(child.get('x'))
|
||||
y_val = _first_number(child.get('y'))
|
||||
dx_val = _first_number(child.get('dx'))
|
||||
dy_val = _first_number(child.get('dy'))
|
||||
if x_val is not None:
|
||||
cur_x = x_val
|
||||
elif dx_val is not None:
|
||||
cur_x = (cur_x or 0.0) + dx_val
|
||||
if y_val is not None:
|
||||
cur_y = y_val
|
||||
elif dy_val is not None:
|
||||
cur_y = (cur_y or 0.0) + dy_val
|
||||
if child is tspan_el:
|
||||
break
|
||||
if cur_x is None or cur_y is None:
|
||||
return None
|
||||
return cur_x, cur_y
|
||||
|
||||
|
||||
def _adjust_following_tspan_dy(
|
||||
text_el: ET.Element,
|
||||
target: ET.Element,
|
||||
) -> None:
|
||||
"""Keep later line-break tspans visually stable when one sibling is removed."""
|
||||
children = list(text_el)
|
||||
try:
|
||||
idx = children.index(target)
|
||||
except ValueError:
|
||||
return
|
||||
if idx + 1 >= len(children):
|
||||
return
|
||||
next_el = children[idx + 1]
|
||||
if _local_name(next_el) != 'tspan' or next_el.get('y') is not None or next_el.get('dy') is None:
|
||||
return
|
||||
next_baseline = _tspan_baseline(text_el, next_el)
|
||||
if next_baseline is None:
|
||||
return
|
||||
prev_y: Optional[float] = None
|
||||
for prior in reversed(children[:idx]):
|
||||
if _local_name(prior) != 'tspan':
|
||||
continue
|
||||
prior_baseline = _tspan_baseline(text_el, prior)
|
||||
if prior_baseline is not None:
|
||||
prev_y = prior_baseline[1]
|
||||
break
|
||||
if prev_y is None:
|
||||
prev_y = _first_number(text_el.get('y')) or 0.0
|
||||
next_el.set('dy', _format_number(next_baseline[1] - prev_y))
|
||||
|
||||
|
||||
def _copy_text_attrs(src: ET.Element, dst: ET.Element, skip: set[str]) -> None:
|
||||
for key, value in src.attrib.items():
|
||||
if key not in skip:
|
||||
dst.set(key, value)
|
||||
|
||||
|
||||
def promote_tspan_to_text(
|
||||
root: ET.Element,
|
||||
element_id: str,
|
||||
x: str,
|
||||
y: str,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Promote a moved direct-child <tspan> into an independent <text>.
|
||||
|
||||
Writing vertical movement into ``dy`` changes the baseline for following
|
||||
tspans. Promotion preserves the edited line as its own object whose final
|
||||
position lives in x/y, while adjacent lines remain anchored in the parent.
|
||||
"""
|
||||
target, text_el = _find_with_parent(root, element_id)
|
||||
if target is None or text_el is None:
|
||||
return False, 'not-found'
|
||||
if _local_name(target) != 'tspan' or _local_name(text_el) != 'text':
|
||||
return False, 'not-tspan'
|
||||
|
||||
grandparent: Optional[ET.Element] = None
|
||||
for candidate in root.iter():
|
||||
if text_el in list(candidate):
|
||||
grandparent = candidate
|
||||
break
|
||||
if grandparent is None:
|
||||
return False, 'parent-not-found'
|
||||
|
||||
_adjust_following_tspan_dy(text_el, target)
|
||||
|
||||
new_text = ET.Element(f'{{{SVG_NS}}}text')
|
||||
_copy_text_attrs(text_el, new_text, {'id', 'x', 'y', 'dx', 'dy'})
|
||||
_copy_text_attrs(target, new_text, {'id', 'x', 'y', 'dx', 'dy', 'transform'})
|
||||
new_text.set('id', element_id)
|
||||
new_text.set('x', x)
|
||||
new_text.set('y', y)
|
||||
|
||||
if len(list(target)) == 0:
|
||||
new_text.text = ''.join(target.itertext())
|
||||
else:
|
||||
new_text.text = target.text
|
||||
for child in list(target):
|
||||
new_text.append(deepcopy(child))
|
||||
|
||||
target_index = list(text_el).index(target)
|
||||
tail = target.tail
|
||||
text_el.remove(target)
|
||||
if tail:
|
||||
if target_index == 0:
|
||||
text_el.text = (text_el.text or '') + tail
|
||||
else:
|
||||
prev = list(text_el)[target_index - 1]
|
||||
prev.tail = (prev.tail or '') + tail
|
||||
|
||||
parent_index = list(grandparent).index(text_el)
|
||||
grandparent.insert(parent_index + 1, new_text)
|
||||
if not (text_el.text or '').strip() and len(list(text_el)) == 0:
|
||||
grandparent.remove(text_el)
|
||||
return True, None
|
||||
|
||||
|
||||
def parse_annotations(root: ET.Element) -> list[dict]:
|
||||
"""Extract all annotations from an SVG element tree."""
|
||||
annotations = []
|
||||
for elem in root.iter():
|
||||
if elem.get('data-edit-target') == 'true':
|
||||
annotations.append({
|
||||
'element_id': elem.get('id', ''),
|
||||
'tag': elem.tag.split('}', 1)[1] if '}' in elem.tag else elem.tag,
|
||||
'annotation': elem.get('data-edit-annotation', ''),
|
||||
})
|
||||
return annotations
|
||||
|
||||
|
||||
def set_annotation(root: ET.Element, element_id: str, annotation: str) -> bool:
|
||||
"""Add or update an annotation on an SVG element. Returns True if found."""
|
||||
elem = _find_by_id(root, element_id)
|
||||
if elem is None:
|
||||
return False
|
||||
elem.set('data-edit-target', 'true')
|
||||
elem.set('data-edit-annotation', annotation)
|
||||
return True
|
||||
|
||||
|
||||
def remove_annotation(root: ET.Element, element_id: str) -> bool:
|
||||
"""Remove annotation attributes from an SVG element. Returns True if found."""
|
||||
elem = _find_by_id(root, element_id)
|
||||
if elem is None:
|
||||
return False
|
||||
elem.attrib.pop('data-edit-target', None)
|
||||
elem.attrib.pop('data-edit-annotation', None)
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct (AI-free) editing — used by server.py POST /api/slide/<name>/edit.
|
||||
# These mutate the element itself (text content / presentation attributes)
|
||||
# instead of leaving an annotation marker for the AI to act on. Value
|
||||
# validation is the caller's responsibility; these helpers only write.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Attributes that must never be edited from the browser property panel.
|
||||
PROTECTED_ATTRS = frozenset({
|
||||
'id', 'class', 'data-edit-target', 'data-edit-annotation',
|
||||
})
|
||||
PROTECTED_ATTR_SUFFIXES = frozenset({
|
||||
'href',
|
||||
})
|
||||
|
||||
|
||||
def is_editable_attr(key: str) -> bool:
|
||||
"""Return True when a raw SVG attribute is safe to edit from the UI."""
|
||||
key_lower = key.lower()
|
||||
if key_lower in PROTECTED_ATTRS:
|
||||
return False
|
||||
if key_lower.startswith('on'):
|
||||
return False
|
||||
if key_lower in PROTECTED_ATTR_SUFFIXES or key_lower.endswith(':href'):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def set_text(root: ET.Element, element_id: str, text: str) -> tuple[bool, Optional[str]]:
|
||||
"""Set an element's text content (L1). Returns (ok, reason).
|
||||
|
||||
Refuses elements that own <tspan> children: overwriting ``.text`` there
|
||||
would orphan the tspans and destroy the multi-line layout. The caller
|
||||
should target the specific <tspan> instead.
|
||||
"""
|
||||
elem = _find_by_id(root, element_id)
|
||||
if elem is None:
|
||||
return False, 'not-found'
|
||||
for child in elem:
|
||||
ctag = child.tag.split('}', 1)[1] if '}' in child.tag else child.tag
|
||||
if ctag == 'tspan':
|
||||
return False, 'has-tspan-children'
|
||||
elem.text = text
|
||||
return True, None
|
||||
|
||||
|
||||
def set_attributes(
|
||||
root: ET.Element, element_id: str, attrs: dict,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Set whitelisted presentation attributes (L2). Returns (ok, reason).
|
||||
|
||||
Enforces is_editable_attr as a hard gate (defence in depth — server.py
|
||||
also validates values before calling here). Writes nothing if any key is
|
||||
disallowed, so a rejected request leaves the element untouched.
|
||||
"""
|
||||
elem = _find_by_id(root, element_id)
|
||||
if elem is None:
|
||||
return False, 'not-found'
|
||||
for key in attrs:
|
||||
if not is_editable_attr(key):
|
||||
return False, f'attr-not-allowed:{key}'
|
||||
for key, value in attrs.items():
|
||||
if value is None:
|
||||
elem.attrib.pop(key, None)
|
||||
else:
|
||||
elem.set(key, str(value))
|
||||
return True, None
|
||||
|
||||
|
||||
def remove_attribute(
|
||||
root: ET.Element, element_id: str, key: str,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""Remove a whitelisted attribute (used by undo when the old value was unset).
|
||||
|
||||
Returns (ok, reason). Enforces is_editable_attr so undo can only touch the
|
||||
same surface a direct edit could.
|
||||
"""
|
||||
if not is_editable_attr(key):
|
||||
return False, f'attr-not-allowed:{key}'
|
||||
elem = _find_by_id(root, element_id)
|
||||
if elem is None:
|
||||
return False, 'not-found'
|
||||
elem.attrib.pop(key, None)
|
||||
return True, None
|
||||
|
||||
|
||||
def strip_unused_temp_ids(root: ET.Element, keep_ids: set) -> None:
|
||||
"""Drop transient ``_edit_N`` ids except those in ``keep_ids`` and any
|
||||
element still carrying a submitted annotation (its id is the AI's locator).
|
||||
|
||||
Mirrors the cleanup in server.py's save-all so a direct edit never
|
||||
strips the id an unsaved/saved annotation depends on.
|
||||
"""
|
||||
protected = set(keep_ids)
|
||||
for elem in root.iter():
|
||||
if elem.get('data-edit-target') == 'true':
|
||||
eid = elem.get('id')
|
||||
if eid:
|
||||
protected.add(eid)
|
||||
for elem in root.iter():
|
||||
eid = elem.get('id', '')
|
||||
if eid.startswith('_edit_') and eid not in protected:
|
||||
elem.attrib.pop('id', None)
|
||||
Reference in New Issue
Block a user