Skip to content

Commit 021d734

Browse files
hugohe3claude
andcommitted
feat(svg-editor): add drag-to-move, arrow nudge, and overlap picker
Direct canvas manipulation in live preview: press-drag a selected element to reposition (whole selection under multi-select), arrow keys nudge 1px (Shift=10px), right-click lists stacked elements to disambiguate. Moves reuse attrsForMove + staging + backend coalescing with optimistic preview and rollback on failure. Moved tspans promote to standalone <text> to avoid shifting sibling baselines; icon moves persist via data-use-x/y + transform so the finalize/export pipeline reproduces the edited geometry. convert_text now absorbs pure-translate transforms on text into the frame position. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dd6021f commit 021d734

7 files changed

Lines changed: 972 additions & 35 deletions

File tree

skills/ppt-master/scripts/svg_editor/annotations.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"""
1515

1616
import xml.etree.ElementTree as ET
17+
import re
18+
from copy import deepcopy
1719
from typing import Optional
1820

1921
SVG_NS = 'http://www.w3.org/2000/svg'
@@ -50,6 +52,154 @@ def _find_by_id(root: ET.Element, element_id: str) -> Optional[ET.Element]:
5052
return None
5153

5254

55+
def _find_with_parent(
56+
root: ET.Element, element_id: str,
57+
) -> tuple[Optional[ET.Element], Optional[ET.Element]]:
58+
"""Find an element and its parent by id."""
59+
for parent in root.iter():
60+
for child in list(parent):
61+
if child.get('id') == element_id:
62+
return child, parent
63+
return None, None
64+
65+
66+
def _local_name(elem: ET.Element) -> str:
67+
return elem.tag.split('}', 1)[1] if '}' in elem.tag else elem.tag
68+
69+
70+
def _first_number(value: Optional[str]) -> Optional[float]:
71+
if value is None:
72+
return None
73+
match = re.search(r'-?\d+(?:\.\d+)?', value)
74+
return float(match.group(0)) if match else None
75+
76+
77+
def _format_number(value: float) -> str:
78+
text = f'{value:.3f}'.rstrip('0').rstrip('.')
79+
return text or '0'
80+
81+
82+
def _tspan_baseline(text_el: ET.Element, tspan_el: ET.Element) -> Optional[tuple[float, float]]:
83+
cur_x = _first_number(text_el.get('x'))
84+
cur_y = _first_number(text_el.get('y'))
85+
for child in list(text_el):
86+
if _local_name(child) != 'tspan':
87+
continue
88+
x_val = _first_number(child.get('x'))
89+
y_val = _first_number(child.get('y'))
90+
dx_val = _first_number(child.get('dx'))
91+
dy_val = _first_number(child.get('dy'))
92+
if x_val is not None:
93+
cur_x = x_val
94+
elif dx_val is not None:
95+
cur_x = (cur_x or 0.0) + dx_val
96+
if y_val is not None:
97+
cur_y = y_val
98+
elif dy_val is not None:
99+
cur_y = (cur_y or 0.0) + dy_val
100+
if child is tspan_el:
101+
break
102+
if cur_x is None or cur_y is None:
103+
return None
104+
return cur_x, cur_y
105+
106+
107+
def _adjust_following_tspan_dy(
108+
text_el: ET.Element,
109+
target: ET.Element,
110+
) -> None:
111+
"""Keep later line-break tspans visually stable when one sibling is removed."""
112+
children = list(text_el)
113+
try:
114+
idx = children.index(target)
115+
except ValueError:
116+
return
117+
if idx + 1 >= len(children):
118+
return
119+
next_el = children[idx + 1]
120+
if _local_name(next_el) != 'tspan' or next_el.get('y') is not None or next_el.get('dy') is None:
121+
return
122+
next_baseline = _tspan_baseline(text_el, next_el)
123+
if next_baseline is None:
124+
return
125+
prev_y: Optional[float] = None
126+
for prior in reversed(children[:idx]):
127+
if _local_name(prior) != 'tspan':
128+
continue
129+
prior_baseline = _tspan_baseline(text_el, prior)
130+
if prior_baseline is not None:
131+
prev_y = prior_baseline[1]
132+
break
133+
if prev_y is None:
134+
prev_y = _first_number(text_el.get('y')) or 0.0
135+
next_el.set('dy', _format_number(next_baseline[1] - prev_y))
136+
137+
138+
def _copy_text_attrs(src: ET.Element, dst: ET.Element, skip: set[str]) -> None:
139+
for key, value in src.attrib.items():
140+
if key not in skip:
141+
dst.set(key, value)
142+
143+
144+
def promote_tspan_to_text(
145+
root: ET.Element,
146+
element_id: str,
147+
x: str,
148+
y: str,
149+
) -> tuple[bool, Optional[str]]:
150+
"""Promote a moved direct-child <tspan> into an independent <text>.
151+
152+
Writing vertical movement into ``dy`` changes the baseline for following
153+
tspans. Promotion preserves the edited line as its own object whose final
154+
position lives in x/y, while adjacent lines remain anchored in the parent.
155+
"""
156+
target, text_el = _find_with_parent(root, element_id)
157+
if target is None or text_el is None:
158+
return False, 'not-found'
159+
if _local_name(target) != 'tspan' or _local_name(text_el) != 'text':
160+
return False, 'not-tspan'
161+
162+
grandparent: Optional[ET.Element] = None
163+
for candidate in root.iter():
164+
if text_el in list(candidate):
165+
grandparent = candidate
166+
break
167+
if grandparent is None:
168+
return False, 'parent-not-found'
169+
170+
_adjust_following_tspan_dy(text_el, target)
171+
172+
new_text = ET.Element(f'{{{SVG_NS}}}text')
173+
_copy_text_attrs(text_el, new_text, {'id', 'x', 'y', 'dx', 'dy'})
174+
_copy_text_attrs(target, new_text, {'id', 'x', 'y', 'dx', 'dy', 'transform'})
175+
new_text.set('id', element_id)
176+
new_text.set('x', x)
177+
new_text.set('y', y)
178+
179+
if len(list(target)) == 0:
180+
new_text.text = ''.join(target.itertext())
181+
else:
182+
new_text.text = target.text
183+
for child in list(target):
184+
new_text.append(deepcopy(child))
185+
186+
target_index = list(text_el).index(target)
187+
tail = target.tail
188+
text_el.remove(target)
189+
if tail:
190+
if target_index == 0:
191+
text_el.text = (text_el.text or '') + tail
192+
else:
193+
prev = list(text_el)[target_index - 1]
194+
prev.tail = (prev.tail or '') + tail
195+
196+
parent_index = list(grandparent).index(text_el)
197+
grandparent.insert(parent_index + 1, new_text)
198+
if not (text_el.text or '').strip() and len(list(text_el)) == 0:
199+
grandparent.remove(text_el)
200+
return True, None
201+
202+
53203
def parse_annotations(root: ET.Element) -> list[dict]:
54204
"""Extract all annotations from an SVG element tree."""
55205
annotations = []
@@ -145,7 +295,10 @@ def set_attributes(
145295
if not is_editable_attr(key):
146296
return False, f'attr-not-allowed:{key}'
147297
for key, value in attrs.items():
148-
elem.set(key, str(value))
298+
if value is None:
299+
elem.attrib.pop(key, None)
300+
else:
301+
elem.set(key, str(value))
149302
return True, None
150303

151304

skills/ppt-master/scripts/svg_editor/server.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
import argparse
2121
import atexit
22+
import html
2223
import json
2324
import logging
2425
import os
@@ -53,6 +54,7 @@
5354
assign_temp_ids,
5455
is_editable_attr,
5556
parse_annotations,
57+
promote_tspan_to_text,
5658
set_annotation,
5759
set_attributes,
5860
set_text,
@@ -78,6 +80,11 @@
7880
_LIST_CACHE: dict = {} # path -> (mtime, annotation_count_on_disk)
7981

8082

83+
def _xml_attr(value: object) -> str:
84+
"""Escape a value for safe insertion into generated preview SVG markup."""
85+
return html.escape(str(value), quote=True)
86+
87+
8188
def _cache_get(cache: dict, lock: threading.Lock, path: str, mtime: float):
8289
with lock:
8390
entry = cache.get(path)
@@ -181,8 +188,17 @@ def _inline_icons(content: str) -> tuple[str, list[dict]]:
181188
replacement = generate_icon_group(attrs, elements, style, base_size)
182189
id_match = re.search(r'\bid="([^"]+)"', use_str)
183190
if id_match:
191+
preview_attrs = [
192+
f'id="{_xml_attr(id_match.group(1))}"',
193+
f'data-icon="{_xml_attr(icon_name)}"',
194+
]
195+
for key in ('x', 'y', 'width', 'height'):
196+
if key in attrs:
197+
preview_attrs.append(f'data-use-{key}="{_xml_attr(attrs[key])}"')
198+
if 'transform' in attrs:
199+
preview_attrs.append('data-use-has-transform="1"')
184200
replacement = replacement.replace(
185-
'<g ', f'<g id="{id_match.group(1)}" data-icon="{icon_name}" ', 1,
201+
'<g ', f'<g {" ".join(preview_attrs)} ', 1,
186202
)
187203
new_content = new_content[:match.start()] + replacement + new_content[match.end():]
188204
return new_content, warnings
@@ -219,6 +235,10 @@ def _validate_edit_attrs(attrs: dict, existing_attrs: set[str]) -> Optional[str]
219235
return f'attribute not editable: {key}'
220236
if key not in existing_attrs and key != 'transform' and key not in _ADDABLE_BATCH_ATTRS:
221237
return f'attribute does not exist on element: {key}'
238+
if value is None:
239+
if key not in existing_attrs:
240+
return f'attribute does not exist on element: {key}'
241+
continue
222242
if not isinstance(value, str):
223243
return f'value must be a string: {key}'
224244
if len(value) > _MAX_ATTR_VALUE_LEN:
@@ -262,6 +282,18 @@ def _apply_edit_record(root: ET.Element, record: dict) -> tuple[bool, Optional[s
262282
element_id = record.get('element_id')
263283
if not isinstance(element_id, str):
264284
return False, 'invalid-record'
285+
promote = record.get('promote_tspan')
286+
if promote:
287+
if not isinstance(promote, dict):
288+
return False, 'invalid-promote'
289+
ok, reason = promote_tspan_to_text(
290+
root,
291+
element_id,
292+
str(promote.get('x') or ''),
293+
str(promote.get('y') or ''),
294+
)
295+
if not ok:
296+
return ok, reason
265297
if 'text' in record:
266298
ok, reason = set_text(root, element_id, str(record.get('text') or ''))
267299
if not ok:
@@ -290,7 +322,8 @@ def _edit_signature(record: dict) -> tuple:
290322
collapses to one undo step; 'change fill then font-size' stays two.
291323
"""
292324
attr_keys = tuple(sorted((record.get('attrs') or {}).keys()))
293-
return (record.get('element_id'), 'text' in record, attr_keys)
325+
promote_keys = tuple(sorted((record.get('promote_tspan') or {}).keys()))
326+
return (record.get('element_id'), 'text' in record, attr_keys, promote_keys)
294327

295328

296329
def _coalesce_into(prev: dict, cur: dict) -> None:
@@ -307,6 +340,8 @@ def _coalesce_into(prev: dict, cur: dict) -> None:
307340
merged = dict(prev.get('attrs') or {})
308341
merged.update(cur['attrs'])
309342
prev['attrs'] = merged
343+
if cur.get('promote_tspan'):
344+
prev['promote_tspan'] = cur['promote_tspan']
310345
old_by_field = {(c['kind'], c['key']): c['old'] for c in prev['changes']}
311346
prev['changes'] = [
312347
{
@@ -624,7 +659,8 @@ def post_edit(name: str):
624659

625660
new_text = data.get('text')
626661
attrs = data.get('attrs')
627-
if new_text is None and not attrs:
662+
promote = data.get('promote_tspan')
663+
if new_text is None and not attrs and not promote:
628664
return jsonify({'error': 'Nothing to edit (no text or attrs)'}), 400
629665

630666
if new_text is not None:
@@ -633,6 +669,13 @@ def post_edit(name: str):
633669
if attrs is not None:
634670
if not isinstance(attrs, dict):
635671
return jsonify({'error': 'attrs must be an object'}), 400
672+
if promote is not None:
673+
if not isinstance(promote, dict):
674+
return jsonify({'error': 'promote_tspan must be an object'}), 400
675+
for key in ('x', 'y'):
676+
value = promote.get(key)
677+
if not isinstance(value, str) or not re.fullmatch(r'-?\d+(?:\.\d+)?', value):
678+
return jsonify({'error': f'invalid promote_tspan.{key}'}), 400
636679

637680
try:
638681
tree = ET.parse(str(svg_file))
@@ -677,6 +720,27 @@ def post_edit(name: str):
677720
for k, v in attrs.items():
678721
changes.append({'kind': 'attr', 'key': k, 'old': old_attrs[k], 'new': v})
679722
staged['attrs'] = attrs
723+
if promote:
724+
tag = target.tag.split('}', 1)[1] if '}' in target.tag else target.tag
725+
old_state = {
726+
'tag': tag,
727+
'x': target.get('x'),
728+
'y': target.get('y'),
729+
'dy': target.get('dy'),
730+
'transform': target.get('transform'),
731+
}
732+
ok, reason = promote_tspan_to_text(root, element_id, promote['x'], promote['y'])
733+
if not ok:
734+
return jsonify({'error': f'Tspan promotion failed: {reason}'}), (
735+
404 if reason == 'not-found' else 400
736+
)
737+
changes.append({
738+
'kind': 'structure',
739+
'key': 'promote-tspan',
740+
'old': old_state,
741+
'new': {'tag': 'text', 'x': promote['x'], 'y': promote['y']},
742+
})
743+
staged['promote_tspan'] = promote
680744

681745
staged['changes'] = changes
682746
pending = app.config['PENDING_EDITS'].setdefault(name, [])

0 commit comments

Comments
 (0)