返回 ppt-master
annotations.py
根目录 / skills / ppt-master / scripts / svg_editor / annotations.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - SVG Annotation Utilities
4
5 Read, write, and manage edit annotations in SVG files.
6 Annotations are stored as custom XML attributes (data-edit-target, data-edit-annotation)
7 on SVG elements, enabling AI-driven targeted editing.
8
9 Usage:
10 (library module — imported by server.py and check_annotations.py)
11
12 Dependencies:
13 None (only uses standard library)
14 """
15
16 import xml.etree.ElementTree as ET
17 import re
18 from copy import deepcopy
19 from typing import Optional
20
21 SVG_NS = 'http://www.w3.org/2000/svg'
22 XLINK_NS = 'http://www.w3.org/1999/xlink'
23
24 # Register namespaces to avoid generated ns0/ns1 prefixes in saved SVGs.
25 ET.register_namespace('', SVG_NS)
26 ET.register_namespace('xlink', XLINK_NS)
27
28
29 def assign_temp_ids(root: ET.Element) -> None:
30 """Assign deterministic temp ids (_edit_0, _edit_1, ...) to elements without one.
31
32 Clears any leftover _edit_N ids from previous sessions first, to avoid
33 shifted numbering when elements are added/removed between sessions.
34 """
35 for elem in root.iter():
36 eid = elem.get('id', '')
37 if eid.startswith('_edit_'):
38 elem.attrib.pop('id', None)
39
40 counter = 0
41 for elem in root.iter():
42 if elem is root:
43 continue
44 if elem.get('id') is None:
45 elem.set('id', f'_edit_{counter}')
46 counter += 1
47
48
49 def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
50 """Find an element by its id attribute in the SVG tree."""
51 for elem in root.iter():
52 if elem.get('id') == element_id:
53 return elem
54 return None
55
56
57 def _find_with_parent(
58 root: ET.Element, element_id: str,
59 ) -> tuple[Optional[ET.Element], Optional[ET.Element]]:
60 """Find an element and its parent by id."""
61 for parent in root.iter():
62 for child in list(parent):
63 if child.get('id') == element_id:
64 return child, parent
65 return None, None
66
67
68 def _local_name(elem: ET.Element) -> str:
69 return elem.tag.split('}', 1)[1] if '}' in elem.tag else elem.tag
70
71
72 def _first_number(value: Optional[str]) -> Optional[float]:
73 if value is None:
74 return None
75 match = re.search(r'-?\d+(?:\.\d+)?', value)
76 return float(match.group(0)) if match else None
77
78
79 def _format_number(value: float) -> str:
80 text = f'{value:.3f}'.rstrip('0').rstrip('.')
81 return text or '0'
82
83
84 def _tspan_baseline(text_el: ET.Element, tspan_el: ET.Element) -> Optional[tuple[float, float]]:
85 cur_x = _first_number(text_el.get('x'))
86 cur_y = _first_number(text_el.get('y'))
87 for child in list(text_el):
88 if _local_name(child) != 'tspan':
89 continue
90 x_val = _first_number(child.get('x'))
91 y_val = _first_number(child.get('y'))
92 dx_val = _first_number(child.get('dx'))
93 dy_val = _first_number(child.get('dy'))
94 if x_val is not None:
95 cur_x = x_val
96 elif dx_val is not None:
97 cur_x = (cur_x or 0.0) + dx_val
98 if y_val is not None:
99 cur_y = y_val
100 elif dy_val is not None:
101 cur_y = (cur_y or 0.0) + dy_val
102 if child is tspan_el:
103 break
104 if cur_x is None or cur_y is None:
105 return None
106 return cur_x, cur_y
107
108
109 def _adjust_following_tspan_dy(
110 text_el: ET.Element,
111 target: ET.Element,
112 ) -> None:
113 """Keep later line-break tspans visually stable when one sibling is removed."""
114 children = list(text_el)
115 try:
116 idx = children.index(target)
117 except ValueError:
118 return
119 if idx + 1 >= len(children):
120 return
121 next_el = children[idx + 1]
122 if _local_name(next_el) != 'tspan' or next_el.get('y') is not None or next_el.get('dy') is None:
123 return
124 next_baseline = _tspan_baseline(text_el, next_el)
125 if next_baseline is None:
126 return
127 prev_y: Optional[float] = None
128 for prior in reversed(children[:idx]):
129 if _local_name(prior) != 'tspan':
130 continue
131 prior_baseline = _tspan_baseline(text_el, prior)
132 if prior_baseline is not None:
133 prev_y = prior_baseline[1]
134 break
135 if prev_y is None:
136 prev_y = _first_number(text_el.get('y')) or 0.0
137 next_el.set('dy', _format_number(next_baseline[1] - prev_y))
138
139
140 def _copy_text_attrs(src: ET.Element, dst: ET.Element, skip: set[str]) -> None:
141 for key, value in src.attrib.items():
142 if key not in skip:
143 dst.set(key, value)
144
145
146 def promote_tspan_to_text(
147 root: ET.Element,
148 element_id: str,
149 x: str,
150 y: str,
151 ) -> tuple[bool, Optional[str]]:
152 """Promote a moved direct-child <tspan> into an independent <text>.
153
154 Writing vertical movement into ``dy`` changes the baseline for following
155 tspans. Promotion preserves the edited line as its own object whose final
156 position lives in x/y, while adjacent lines remain anchored in the parent.
157 """
158 target, text_el = _find_with_parent(root, element_id)
159 if target is None or text_el is None:
160 return False, 'not-found'
161 if _local_name(target) != 'tspan' or _local_name(text_el) != 'text':
162 return False, 'not-tspan'
163
164 grandparent: Optional[ET.Element] = None
165 for candidate in root.iter():
166 if text_el in list(candidate):
167 grandparent = candidate
168 break
169 if grandparent is None:
170 return False, 'parent-not-found'
171
172 _adjust_following_tspan_dy(text_el, target)
173
174 new_text = ET.Element(f'{{{SVG_NS}}}text')
175 _copy_text_attrs(text_el, new_text, {'id', 'x', 'y', 'dx', 'dy'})
176 _copy_text_attrs(target, new_text, {'id', 'x', 'y', 'dx', 'dy', 'transform'})
177 new_text.set('id', element_id)
178 new_text.set('x', x)
179 new_text.set('y', y)
180
181 if len(list(target)) == 0:
182 new_text.text = ''.join(target.itertext())
183 else:
184 new_text.text = target.text
185 for child in list(target):
186 new_text.append(deepcopy(child))
187
188 target_index = list(text_el).index(target)
189 tail = target.tail
190 text_el.remove(target)
191 if tail:
192 if target_index == 0:
193 text_el.text = (text_el.text or '') + tail
194 else:
195 prev = list(text_el)[target_index - 1]
196 prev.tail = (prev.tail or '') + tail
197
198 parent_index = list(grandparent).index(text_el)
199 grandparent.insert(parent_index + 1, new_text)
200 if not (text_el.text or '').strip() and len(list(text_el)) == 0:
201 grandparent.remove(text_el)
202 return True, None
203
204
205 def parse_annotations(root: ET.Element) -> list[dict]:
206 """Extract all annotations from an SVG element tree."""
207 annotations = []
208 for elem in root.iter():
209 if elem.get('data-edit-target') == 'true':
210 annotations.append({
211 'element_id': elem.get('id', ''),
212 'tag': elem.tag.split('}', 1)[1] if '}' in elem.tag else elem.tag,
213 'annotation': elem.get('data-edit-annotation', ''),
214 })
215 return annotations
216
217
218 def set_annotation(root: ET.Element, element_id: str, annotation: str) -> bool:
219 """Add or update an annotation on an SVG element. Returns True if found."""
220 elem = _find_by_id(root, element_id)
221 if elem is None:
222 return False
223 elem.set('data-edit-target', 'true')
224 elem.set('data-edit-annotation', annotation)
225 return True
226
227
228 def remove_annotation(root: ET.Element, element_id: str) -> bool:
229 """Remove annotation attributes from an SVG element. Returns True if found."""
230 elem = _find_by_id(root, element_id)
231 if elem is None:
232 return False
233 elem.attrib.pop('data-edit-target', None)
234 elem.attrib.pop('data-edit-annotation', None)
235 return True
236
237
238 # ---------------------------------------------------------------------------
239 # Direct (AI-free) editing — used by server.py POST /api/slide/<name>/edit.
240 # These mutate the element itself (text content / presentation attributes)
241 # instead of leaving an annotation marker for the AI to act on. Value
242 # validation is the caller's responsibility; these helpers only write.
243 # ---------------------------------------------------------------------------
244
245 # Attributes that must never be edited from the browser property panel.
246 PROTECTED_ATTRS = frozenset({
247 'id', 'class', 'data-edit-target', 'data-edit-annotation',
248 })
249 PROTECTED_ATTR_SUFFIXES = frozenset({
250 'href',
251 })
252
253
254 def is_editable_attr(key: str) -> bool:
255 """Return True when a raw SVG attribute is safe to edit from the UI."""
256 key_lower = key.lower()
257 if key_lower in PROTECTED_ATTRS:
258 return False
259 if key_lower.startswith('on'):
260 return False
261 if key_lower in PROTECTED_ATTR_SUFFIXES or key_lower.endswith(':href'):
262 return False
263 return True
264
265
266 def set_text(root: ET.Element, element_id: str, text: str) -> tuple[bool, Optional[str]]:
267 """Set an element's text content (L1). Returns (ok, reason).
268
269 Refuses elements that own <tspan> children: overwriting ``.text`` there
270 would orphan the tspans and destroy the multi-line layout. The caller
271 should target the specific <tspan> instead.
272 """
273 elem = _find_by_id(root, element_id)
274 if elem is None:
275 return False, 'not-found'
276 for child in elem:
277 ctag = child.tag.split('}', 1)[1] if '}' in child.tag else child.tag
278 if ctag == 'tspan':
279 return False, 'has-tspan-children'
280 elem.text = text
281 return True, None
282
283
284 def set_attributes(
285 root: ET.Element, element_id: str, attrs: dict,
286 ) -> tuple[bool, Optional[str]]:
287 """Set whitelisted presentation attributes (L2). Returns (ok, reason).
288
289 Enforces is_editable_attr as a hard gate (defence in depth — server.py
290 also validates values before calling here). Writes nothing if any key is
291 disallowed, so a rejected request leaves the element untouched.
292 """
293 elem = _find_by_id(root, element_id)
294 if elem is None:
295 return False, 'not-found'
296 for key in attrs:
297 if not is_editable_attr(key):
298 return False, f'attr-not-allowed:{key}'
299 for key, value in attrs.items():
300 if value is None:
301 elem.attrib.pop(key, None)
302 else:
303 elem.set(key, str(value))
304 return True, None
305
306
307 def remove_attribute(
308 root: ET.Element, element_id: str, key: str,
309 ) -> tuple[bool, Optional[str]]:
310 """Remove a whitelisted attribute (used by undo when the old value was unset).
311
312 Returns (ok, reason). Enforces is_editable_attr so undo can only touch the
313 same surface a direct edit could.
314 """
315 if not is_editable_attr(key):
316 return False, f'attr-not-allowed:{key}'
317 elem = _find_by_id(root, element_id)
318 if elem is None:
319 return False, 'not-found'
320 elem.attrib.pop(key, None)
321 return True, None
322
323
324 def strip_unused_temp_ids(root: ET.Element, keep_ids: set) -> None:
325 """Drop transient ``_edit_N`` ids except those in ``keep_ids`` and any
326 element still carrying a submitted annotation (its id is the AI's locator).
327
328 Mirrors the cleanup in server.py's save-all so a direct edit never
329 strips the id an unsaved/saved annotation depends on.
330 """
331 protected = set(keep_ids)
332 for elem in root.iter():
333 if elem.get('data-edit-target') == 'true':
334 eid = elem.get('id')
335 if eid:
336 protected.add(eid)
337 for elem in root.iter():
338 eid = elem.get('id', '')
339 if eid.startswith('_edit_') and eid not in protected:
340 elem.attrib.pop('id', None)
341
341 lines PYTHON