diff --git a/src/artifact_editor/gui.py b/src/artifact_editor/gui.py
index b77231b..ae4cfdd 100644
--- a/src/artifact_editor/gui.py
+++ b/src/artifact_editor/gui.py
@@ -15,7 +15,7 @@ from enum import Enum, auto
from pathlib import Path
from typing import Optional
-from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, QRectF, QPointF
+from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QThread, QRectF, QPointF, QSizeF
from PyQt6.QtGui import (
QFont, QColor, QAction, QKeySequence, QSyntaxHighlighter,
QTextCharFormat, QPainter, QPen, QBrush
@@ -26,11 +26,12 @@ from PyQt6.QtWidgets import (
QFileDialog, QMessageBox, QStatusBar, QToolBar, QComboBox,
QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsEllipseItem,
QGraphicsTextItem, QGraphicsLineItem, QGraphicsItem,
- QListWidget, QListWidgetItem, QGroupBox
+ QListWidget, QListWidgetItem, QGroupBox, QSpinBox, QCheckBox, QDialog
)
from PyQt6.QtSvgWidgets import QSvgWidget
from .renderers import get_renderer, list_renderers
+from .renderers.code import get_languages, get_themes, is_available as code_renderer_available
from .dialogs import (
DIAGRAM_TYPES, AddElementDialog, AddRelationshipDialog,
EditElementDialog, DiagramElement,
@@ -39,6 +40,10 @@ from .dialogs import (
EditMermaidElementDialog,
# OpenSCAD dialogs
AddOpenSCADElementDialog, EditOpenSCADElementDialog,
+ # Excalidraw dialogs
+ AddExcalidrawElementDialog, EditExcalidrawElementDialog,
+ # SVG dialogs
+ AddSVGElementDialog, EditSVGElementDialog,
)
from .parser import (
detect_diagram_type, parse_elements, parse_relationships,
@@ -52,6 +57,17 @@ from .openscad_parser import (
parse_openscad_elements, insert_openscad_element, delete_openscad_element,
replace_openscad_element, get_openscad_module_names
)
+from .excalidraw_parser import (
+ parse_excalidraw_elements, add_excalidraw_element, update_excalidraw_element,
+ delete_excalidraw_element, generate_element_id, ExcalidrawElement
+)
+from .svg_parser import (
+ parse_svg_elements, parse_svg_groups, add_svg_element, update_svg_element,
+ delete_svg_element, SVGElement,
+ bring_element_to_front, send_element_to_back, move_element_forward, move_element_backward,
+ create_group, add_to_group, remove_from_group
+)
+from .svg_scene import SVGSceneManager
from .templates import get_templates_for_format, get_template, get_all_templates
@@ -137,25 +153,30 @@ class RenderThread(QThread):
finished = pyqtSignal(bool, str) # success, message/path
def __init__(self, renderer, source: str, output_path: Path,
- camera_angles: tuple[int, int, int] | None = None):
+ camera_angles: tuple[int, int, int] | None = None,
+ code_options: dict | None = None):
super().__init__()
self.renderer = renderer
self.source = source
self.output_path = output_path
self.camera_angles = camera_angles
+ self.code_options = code_options
def run(self):
try:
+ import inspect
+ sig = inspect.signature(self.renderer.render)
+
# Pass camera_angles if renderer supports it (OpenSCAD)
- if self.camera_angles and hasattr(self.renderer, 'render'):
- import inspect
- sig = inspect.signature(self.renderer.render)
- if 'camera_angles' in sig.parameters:
- success, message = self.renderer.render(
- self.source, self.output_path, camera_angles=self.camera_angles
- )
- else:
- success, message = self.renderer.render(self.source, self.output_path)
+ if self.camera_angles and 'camera_angles' in sig.parameters:
+ success, message = self.renderer.render(
+ self.source, self.output_path, camera_angles=self.camera_angles
+ )
+ # Pass code_options if renderer supports it (Code)
+ elif self.code_options and 'code_options' in sig.parameters:
+ success, message = self.renderer.render(
+ self.source, self.output_path, code_options=self.code_options
+ )
else:
success, message = self.renderer.render(self.source, self.output_path)
self.finished.emit(success, message)
@@ -194,9 +215,154 @@ class DictateThread(QThread):
self.finished.emit(f"[Error: {e}]")
+# Format-specific AI prompts with syntax examples
+AI_PROMPTS = {
+ 'plantuml': """You are a PlantUML expert. Generate valid PlantUML code.
+
+PlantUML syntax rules:
+- Must start with @startuml and end with @enduml
+- For class diagrams: class ClassName { +method() -field }
+- For sequence: participant A -> B : message
+- For components: [Component] --> [Other]
+- For activity: start, :action;, if (cond) then, endif, stop
+
+Example:
+```plantuml
+@startuml
+class User {
+ +name: String
+ +login()
+}
+class Order {
+ +items: List
+ +total(): Float
+}
+User --> Order : places
+@enduml
+```""",
+
+ 'mermaid': """You are a Mermaid diagram expert. Generate valid Mermaid code.
+
+Mermaid syntax rules:
+- Flowchart: graph TD or graph LR, then A[Text] --> B{Decision}
+- Sequence: sequenceDiagram, then participant A, A->>B: message
+- Class: classDiagram, then class Name { +method() }
+- State: stateDiagram-v2, then [*] --> State1
+
+Example:
+```mermaid
+graph TD
+ A[Start] --> B{Is valid?}
+ B -->|Yes| C[Process]
+ B -->|No| D[Error]
+ C --> E[End]
+ D --> E
+```""",
+
+ 'openscad': """You are an OpenSCAD 3D modeling expert. Generate valid OpenSCAD code.
+
+OpenSCAD syntax rules:
+- 3D primitives: cube([x,y,z]), sphere(r), cylinder(h, r1, r2)
+- 2D primitives: circle(r), square([x,y]), polygon(points)
+- Transforms: translate([x,y,z]), rotate([x,y,z]), scale([x,y,z])
+- Boolean: union(), difference(), intersection()
+- Extrusion: linear_extrude(height), rotate_extrude()
+- Modules: module name() { ... }
+
+Example:
+```openscad
+difference() {
+ cube([30, 30, 10], center=true);
+ translate([0, 0, 2])
+ cylinder(h=10, r=8, center=true);
+}
+```""",
+
+ 'code': """You are a code formatting expert. Generate clean, well-formatted code.
+
+Rules:
+- Maintain proper indentation
+- Use consistent style
+- Include comments where helpful
+- The code will be syntax highlighted for display
+
+Just output the code with no markdown fences unless the user specifically asks for a particular language.""",
+
+ 'svg': """You are an SVG graphics expert. Generate valid SVG code.
+
+SVG syntax rules:
+- Root element:
+ if '' in source:
+ source = source.replace('', new_element_str + '')
+ else:
+ # Malformed SVG, try to append
+ source = source.rstrip() + '\n' + new_element_str
+
+ return source
+
+
+def _make_num_pattern(value: float) -> str:
+ """Create a regex pattern that matches a number (int or float format)."""
+ int_val = int(value)
+ if value == int_val:
+ # Match both "400" and "400.0"
+ return rf'(?:{int_val}(?:\.0)?|{value})'
+ else:
+ # Match the float value
+ return str(value)
+
+
+def update_svg_element(source: str, element_id: str, updated: SVGElement, original_element: SVGElement = None) -> str:
+ """Update an existing element in the SVG document.
+
+ Args:
+ source: Current SVG XML
+ element_id: ID of element to update
+ updated: Updated element data
+ original_element: The original element (to avoid re-parsing issues with generated IDs)
+
+ Returns:
+ Updated SVG string
+ """
+ # Use the original element if provided (avoids ID mismatch from re-parsing)
+ el = original_element
+
+ if el is None:
+ # Parse to find the element (may not work for elements without real IDs)
+ elements = parse_svg_elements(source)
+ for e in elements:
+ if e.id == element_id:
+ el = e
+ break
+
+ if el is None:
+ return source
+
+ # Build regex pattern to match the element
+ updated.id = element_id # Keep original ID
+ new_xml = updated.to_svg_string()
+
+ # Try to match by id attribute first (if element has id in source)
+ id_pattern = rf'<{re.escape(el.element_type)}[^>]*\sid=["\']?{re.escape(element_id)}["\']?[^>]*(?:/>|>.*?{re.escape(el.element_type)}>)'
+ match = re.search(id_pattern, source, re.DOTALL)
+ if match:
+ return source[:match.start()] + new_xml + source[match.end():]
+
+ # Fallback: match by element type and key attributes (handles any attribute order)
+ tag = el.element_type
+ pattern = None
+
+ if tag == 'rect':
+ # Match rect with these x,y values (attributes can be in any order)
+ x_pat = _make_num_pattern(el.x)
+ y_pat = _make_num_pattern(el.y)
+ pattern = rf']*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*/?>'
+ elif tag == 'circle':
+ cx_pat = _make_num_pattern(el.cx)
+ cy_pat = _make_num_pattern(el.cy)
+ pattern = rf']*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>'
+ elif tag == 'ellipse':
+ cx_pat = _make_num_pattern(el.cx)
+ cy_pat = _make_num_pattern(el.cy)
+ pattern = rf']*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>'
+ elif tag == 'line':
+ x1_pat = _make_num_pattern(el.x1)
+ y1_pat = _make_num_pattern(el.y1)
+ pattern = rf']*\bx1="{x1_pat}")(?=[^>]*\by1="{y1_pat}")[^>]*/?>'
+ elif tag == 'text':
+ x_pat = _make_num_pattern(el.x)
+ y_pat = _make_num_pattern(el.y)
+ pattern = rf']*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?'
+ elif tag == 'path':
+ # Match path by d attribute (escape special chars)
+ d_escaped = re.escape(el.d[:50]) if el.d else ''
+ pattern = rf']*\bd="{d_escaped})[^>]*/?>'
+ elif tag in ('polygon', 'polyline'):
+ points_escaped = re.escape(el.points[:30]) if el.points else ''
+ pattern = rf'<{tag}\s+(?=[^>]*\bpoints="{points_escaped})[^>]*/?>'
+
+ if pattern:
+ match = re.search(pattern, source, re.DOTALL)
+ if match:
+ return source[:match.start()] + new_xml + source[match.end():]
+
+ return source
+
+
+def delete_svg_element(source: str, element_id: str, original_element: SVGElement = None) -> str:
+ """Delete an element from the SVG document.
+
+ Args:
+ source: Current SVG XML
+ element_id: ID of element to delete
+ original_element: The original element (to avoid re-parsing issues with generated IDs)
+
+ Returns:
+ Updated SVG string
+ """
+ # Use the original element if provided
+ el = original_element
+
+ if el is None:
+ elements = parse_svg_elements(source)
+ for e in elements:
+ if e.id == element_id:
+ el = e
+ break
+
+ if el is None:
+ return source
+
+ # Try to match by id attribute first
+ id_pattern = rf'[ \t]*<{re.escape(el.element_type)}[^>]*\sid=["\']?{re.escape(element_id)}["\']?[^>]*(?:/>|>.*?{re.escape(el.element_type)}>)[ \t]*\n?'
+ match = re.search(id_pattern, source, re.DOTALL)
+ if match:
+ return source[:match.start()] + source[match.end():]
+
+ # Fallback: match by element type and key attributes
+ tag = el.element_type
+ pattern = None
+
+ if tag == 'rect':
+ x_pat = _make_num_pattern(el.x)
+ y_pat = _make_num_pattern(el.y)
+ pattern = rf'[ \t]*]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'circle':
+ cx_pat = _make_num_pattern(el.cx)
+ cy_pat = _make_num_pattern(el.cy)
+ pattern = rf'[ \t]*]*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'ellipse':
+ cx_pat = _make_num_pattern(el.cx)
+ cy_pat = _make_num_pattern(el.cy)
+ pattern = rf'[ \t]*]*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'line':
+ x1_pat = _make_num_pattern(el.x1)
+ y1_pat = _make_num_pattern(el.y1)
+ pattern = rf'[ \t]*]*\bx1="{x1_pat}")(?=[^>]*\by1="{y1_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'text':
+ x_pat = _make_num_pattern(el.x)
+ y_pat = _make_num_pattern(el.y)
+ pattern = rf'[ \t]*]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?[ \t]*\n?'
+ elif tag == 'path':
+ d_escaped = re.escape(el.d[:50]) if el.d else ''
+ pattern = rf'[ \t]*]*\bd="{d_escaped})[^>]*/?>[ \t]*\n?'
+ elif tag in ('polygon', 'polyline'):
+ points_escaped = re.escape(el.points[:30]) if el.points else ''
+ pattern = rf'[ \t]*<{tag}\s+(?=[^>]*\bpoints="{points_escaped})[^>]*/?>[ \t]*\n?'
+
+ if pattern:
+ match = re.search(pattern, source, re.DOTALL)
+ if match:
+ return source[:match.start()] + source[match.end():]
+
+ return source
+
+
+# Common SVG colors
+SVG_COLORS = [
+ ('#000000', 'Black'),
+ ('#ffffff', 'White'),
+ ('#ff0000', 'Red'),
+ ('#00ff00', 'Green'),
+ ('#0000ff', 'Blue'),
+ ('#ffff00', 'Yellow'),
+ ('#ff00ff', 'Magenta'),
+ ('#00ffff', 'Cyan'),
+ ('#808080', 'Gray'),
+ ('#800000', 'Maroon'),
+ ('#008000', 'Dark Green'),
+ ('#000080', 'Navy'),
+ ('#808000', 'Olive'),
+ ('#800080', 'Purple'),
+ ('#008080', 'Teal'),
+ ('#c0c0c0', 'Silver'),
+ ('#ffa500', 'Orange'),
+ ('#ffc0cb', 'Pink'),
+ ('#a52a2a', 'Brown'),
+]
+
+SVG_ELEMENT_TYPES = [
+ ('rect', 'Rectangle'),
+ ('circle', 'Circle'),
+ ('ellipse', 'Ellipse'),
+ ('line', 'Line'),
+ ('text', 'Text'),
+ ('path', 'Path'),
+ ('polygon', 'Polygon'),
+ ('polyline', 'Polyline'),
+]
+
+SVG_FONT_FAMILIES = [
+ ('sans-serif', 'Sans-Serif'),
+ ('serif', 'Serif'),
+ ('monospace', 'Monospace'),
+ ('Arial', 'Arial'),
+ ('Helvetica', 'Helvetica'),
+ ('Times New Roman', 'Times'),
+ ('Courier New', 'Courier'),
+ ('Georgia', 'Georgia'),
+ ('Verdana', 'Verdana'),
+]
+
+TEXT_ANCHORS = [
+ ('start', 'Left'),
+ ('middle', 'Center'),
+ ('end', 'Right'),
+]
+
+
+# --- Z-Order Manipulation Functions ---
+
+def _find_element_in_source(source: str, element: SVGElement) -> Optional[re.Match]:
+ """Find an element in the SVG source and return the match object."""
+ tag = element.element_type
+
+ # Try to match by id first
+ if element.id:
+ id_pattern = rf'([ \t]*)<{re.escape(tag)}[^>]*\bid=["\']?{re.escape(element.id)}["\']?[^>]*(?:/>|>.*?{re.escape(tag)}>)'
+ match = re.search(id_pattern, source, re.DOTALL)
+ if match:
+ return match
+
+ # Fallback: match by attributes
+ pattern = None
+ if tag == 'rect':
+ x_pat = _make_num_pattern(element.x)
+ y_pat = _make_num_pattern(element.y)
+ pattern = rf'([ \t]*)]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'circle':
+ cx_pat = _make_num_pattern(element.cx)
+ cy_pat = _make_num_pattern(element.cy)
+ pattern = rf'([ \t]*)]*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'ellipse':
+ cx_pat = _make_num_pattern(element.cx)
+ cy_pat = _make_num_pattern(element.cy)
+ pattern = rf'([ \t]*)]*\bcx="{cx_pat}")(?=[^>]*\bcy="{cy_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'line':
+ x1_pat = _make_num_pattern(element.x1)
+ y1_pat = _make_num_pattern(element.y1)
+ pattern = rf'([ \t]*)]*\bx1="{x1_pat}")(?=[^>]*\by1="{y1_pat}")[^>]*/?>[ \t]*\n?'
+ elif tag == 'text':
+ x_pat = _make_num_pattern(element.x)
+ y_pat = _make_num_pattern(element.y)
+ pattern = rf'([ \t]*)]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?[ \t]*\n?'
+
+ if pattern:
+ return re.search(pattern, source, re.DOTALL)
+ return None
+
+
+def bring_element_to_front(source: str, element: SVGElement) -> str:
+ """Move an element to be rendered last (on top).
+
+ Args:
+ source: SVG source
+ element: Element to move
+
+ Returns:
+ Updated SVG source
+ """
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ # Extract the element XML
+ element_xml = match.group(0)
+ indent = match.group(1) if match.lastindex >= 1 else " "
+
+ # Remove from current position
+ source = source[:match.start()] + source[match.end():]
+
+ # Find the closing tag and insert before it
+ svg_close = source.rfind('')
+ if svg_close == -1:
+ return source
+
+ # Ensure proper formatting
+ element_xml = element_xml.strip()
+ insert_text = f"\n{indent}{element_xml}\n"
+
+ return source[:svg_close] + insert_text + source[svg_close:]
+
+
+def send_element_to_back(source: str, element: SVGElement) -> str:
+ """Move an element to be rendered first (at bottom).
+
+ Args:
+ source: SVG source
+ element: Element to move
+
+ Returns:
+ Updated SVG source
+ """
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ # Extract the element XML
+ element_xml = match.group(0)
+ indent = match.group(1) if match.lastindex >= 1 else " "
+
+ # Remove from current position
+ source = source[:match.start()] + source[match.end():]
+
+ # Find the opening tag end and insert after it
+ svg_open_match = re.search(r']*>', source)
+ if not svg_open_match:
+ return source
+
+ insert_pos = svg_open_match.end()
+
+ # Ensure proper formatting
+ element_xml = element_xml.strip()
+ insert_text = f"\n{indent}{element_xml}"
+
+ return source[:insert_pos] + insert_text + source[insert_pos:]
+
+
+def move_element_forward(source: str, element: SVGElement) -> str:
+ """Move an element one position forward in z-order (swap with next sibling).
+
+ Args:
+ source: SVG source
+ element: Element to move
+
+ Returns:
+ Updated SVG source
+ """
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ element_xml = match.group(0).strip()
+ element_end = match.end()
+
+ # Find the next element after this one
+ shape_pattern = r'<(rect|circle|ellipse|line|text|path|polygon|polyline|g)\s[^>]*(?:/>|>.*?\1>)'
+ next_match = re.search(shape_pattern, source[element_end:], re.DOTALL)
+
+ if not next_match:
+ return source # Already at front
+
+ # Calculate absolute positions
+ next_start = element_end + next_match.start()
+ next_end = element_end + next_match.end()
+ next_xml = next_match.group(0).strip()
+
+ # Swap: remove current, find next, swap positions
+ # Build new source with swapped elements
+ indent = match.group(1) if match.lastindex >= 1 else " "
+ new_source = (
+ source[:match.start()] +
+ f"{indent}{next_xml}\n{indent}{element_xml}" +
+ source[next_end:]
+ )
+
+ return new_source
+
+
+def move_element_backward(source: str, element: SVGElement) -> str:
+ """Move an element one position backward in z-order (swap with previous sibling).
+
+ Args:
+ source: SVG source
+ element: Element to move
+
+ Returns:
+ Updated SVG source
+ """
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ element_xml = match.group(0).strip()
+ element_start = match.start()
+
+ # Find the previous element before this one
+ shape_pattern = r'<(rect|circle|ellipse|line|text|path|polygon|polyline|g)\s[^>]*(?:/>|>.*?\1>)'
+
+ # Search for all elements before this one
+ prev_matches = list(re.finditer(shape_pattern, source[:element_start], re.DOTALL))
+
+ if not prev_matches:
+ return source # Already at back
+
+ prev_match = prev_matches[-1]
+ prev_xml = prev_match.group(0).strip()
+
+ # Build new source with swapped elements
+ indent = match.group(1) if match.lastindex >= 1 else " "
+ new_source = (
+ source[:prev_match.start()] +
+ f"{indent}{element_xml}\n{indent}{prev_xml}" +
+ source[match.end():]
+ )
+
+ return new_source
+
+
+# --- Group Manipulation Functions ---
+
+def create_group(source: str, element: SVGElement, group_id: str = None) -> str:
+ """Create a new group containing the specified element.
+
+ Args:
+ source: SVG source
+ element: Element to wrap in a group
+ group_id: Optional ID for the new group
+
+ Returns:
+ Updated SVG source
+ """
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ element_xml = match.group(0).strip()
+ indent = match.group(1) if match.lastindex >= 1 else " "
+
+ if not group_id:
+ group_id = f"group-{str(uuid.uuid4())[:8]}"
+
+ group_xml = f'{indent}\n{indent} {element_xml}\n{indent}'
+
+ return source[:match.start()] + group_xml + source[match.end():]
+
+
+def add_to_group(source: str, element: SVGElement, group_id: str) -> str:
+ """Move an element into an existing group.
+
+ Args:
+ source: SVG source
+ element: Element to move
+ group_id: ID of target group
+
+ Returns:
+ Updated SVG source
+ """
+ # Find the element
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ element_xml = match.group(0).strip()
+
+ # Remove from current position
+ source = source[:match.start()] + source[match.end():]
+
+ # Find the group's closing tag
+ group_close_pattern = rf'([ \t]*)(?=[^<]*(?:<[^g]|$))' # Find that belongs to our group
+
+ # More reliable: find the group by id, then find its closing tag
+ group_pattern = rf']*\bid="{re.escape(group_id)}"[^>]*>'
+ group_match = re.search(group_pattern, source)
+ if not group_match:
+ return source
+
+ # Find the matching closing (need to handle nesting)
+ group_start = group_match.end()
+ depth = 1
+ pos = group_start
+ while depth > 0 and pos < len(source):
+ open_g = source.find('', pos)
+
+ if close_g == -1:
+ break
+
+ if open_g != -1 and open_g < close_g:
+ depth += 1
+ pos = open_g + 2
+ else:
+ depth -= 1
+ if depth == 0:
+ # Insert element before this
+ insert_pos = close_g
+ # Get indent from group
+ indent_match = re.search(r'\n([ \t]+)', source[close_g-20:close_g+5])
+ indent = indent_match.group(1) + " " if indent_match else " "
+ return source[:insert_pos] + f"{indent}{element_xml}\n" + source[insert_pos:]
+ pos = close_g + 4
+
+ return source
+
+
+def remove_from_group(source: str, element: SVGElement) -> str:
+ """Remove an element from its parent group (move to root level).
+
+ Args:
+ source: SVG source
+ element: Element to remove from group
+
+ Returns:
+ Updated SVG source
+ """
+ if not element.parent_group:
+ return source # Not in a group
+
+ # Find and extract the element
+ match = _find_element_in_source(source, element)
+ if not match:
+ return source
+
+ element_xml = match.group(0).strip()
+
+ # Remove from current position
+ source = source[:match.start()] + source[match.end():]
+
+ # Insert at root level (before )
+ svg_close = source.rfind('')
+ if svg_close == -1:
+ return source
+
+ insert_text = f"\n {element_xml}\n"
+ return source[:svg_close] + insert_text + source[svg_close:]
diff --git a/src/artifact_editor/svg_scene.py b/src/artifact_editor/svg_scene.py
new file mode 100644
index 0000000..536cbdc
--- /dev/null
+++ b/src/artifact_editor/svg_scene.py
@@ -0,0 +1,863 @@
+"""Scene-based SVG editing with real QGraphicsItems.
+
+Each SVG element becomes a QGraphicsItem in the scene, so Qt handles
+all hit-testing and coordinate transforms automatically.
+
+Uses QSvgRenderer.boundsOnElement() to get exact element positions
+matching the actual SVG rendering.
+"""
+
+from typing import Optional, List, Dict
+from PyQt6.QtCore import Qt, QPointF, QRectF, QByteArray, pyqtSignal, QObject
+from PyQt6.QtGui import QPainter, QPen, QBrush, QColor, QFont, QPolygonF, QFontMetricsF
+from PyQt6.QtSvg import QSvgRenderer
+from PyQt6.QtWidgets import (
+ QGraphicsItem, QGraphicsRectItem, QGraphicsScene,
+ QGraphicsView, QGraphicsSceneMouseEvent,
+ QStyleOptionGraphicsItem, QWidget
+)
+
+from .svg_parser import SVGElement, parse_svg_elements
+
+
+# =============================================================================
+# Resize Handle - Corner/edge handles for resizing elements
+# =============================================================================
+
+class ResizeHandle(QGraphicsRectItem):
+ """A small handle for resizing an SVGElementItem."""
+
+ HANDLE_SIZE = 8
+
+ # Handle positions
+ TOP_LEFT = 0
+ TOP_RIGHT = 1
+ BOTTOM_LEFT = 2
+ BOTTOM_RIGHT = 3
+
+ def __init__(self, parent_item: 'SVGElementItem', position: int):
+ super().__init__(parent_item)
+ self.position = position
+ self.parent_item = parent_item
+ self._dragging = False
+ self._start_rect = None
+ self._start_pos = None
+
+ # Appearance
+ self.setRect(0, 0, self.HANDLE_SIZE, self.HANDLE_SIZE)
+ self.setBrush(QBrush(QColor(255, 255, 255)))
+ self.setPen(QPen(QColor(0, 120, 215), 1))
+ self.setZValue(1000)
+
+ # Cursor based on position
+ if position in (self.TOP_LEFT, self.BOTTOM_RIGHT):
+ self.setCursor(Qt.CursorShape.SizeFDiagCursor)
+ else:
+ self.setCursor(Qt.CursorShape.SizeBDiagCursor)
+
+ # Not independently selectable
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
+ self.setAcceptHoverEvents(True)
+
+ self.update_position()
+
+ def update_position(self):
+ """Position the handle at the correct corner of the parent."""
+ rect = self.parent_item.rect()
+ hs = self.HANDLE_SIZE
+
+ if self.position == self.TOP_LEFT:
+ self.setPos(-hs/2, -hs/2)
+ elif self.position == self.TOP_RIGHT:
+ self.setPos(rect.width() - hs/2, -hs/2)
+ elif self.position == self.BOTTOM_LEFT:
+ self.setPos(-hs/2, rect.height() - hs/2)
+ elif self.position == self.BOTTOM_RIGHT:
+ self.setPos(rect.width() - hs/2, rect.height() - hs/2)
+
+ def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
+ """Start resizing."""
+ if event.button() == Qt.MouseButton.LeftButton:
+ self._dragging = True
+ self._start_rect = self.parent_item.rect()
+ self._start_pos = self.parent_item.pos()
+ self._start_scene_pos = event.scenePos()
+ event.accept()
+ else:
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event: QGraphicsSceneMouseEvent):
+ """Handle resize drag."""
+ if self._dragging:
+ delta = event.scenePos() - self._start_scene_pos
+ self._apply_resize(delta.x(), delta.y())
+ event.accept()
+ else:
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent):
+ """Finish resizing."""
+ if event.button() == Qt.MouseButton.LeftButton and self._dragging:
+ self._dragging = False
+ # Notify manager that content changed
+ if self.parent_item.manager:
+ self.parent_item.manager.scene_changed.emit()
+ event.accept()
+ else:
+ super().mouseReleaseEvent(event)
+
+ def _apply_resize(self, dx: float, dy: float):
+ """Apply resize based on handle position and drag delta."""
+ rect = self._start_rect
+ pos = self._start_pos
+
+ new_x = pos.x()
+ new_y = pos.y()
+ new_w = rect.width()
+ new_h = rect.height()
+
+ min_size = 10
+
+ if self.position == self.TOP_LEFT:
+ new_x = pos.x() + dx
+ new_y = pos.y() + dy
+ new_w = rect.width() - dx
+ new_h = rect.height() - dy
+ elif self.position == self.TOP_RIGHT:
+ new_y = pos.y() + dy
+ new_w = rect.width() + dx
+ new_h = rect.height() - dy
+ elif self.position == self.BOTTOM_LEFT:
+ new_x = pos.x() + dx
+ new_w = rect.width() - dx
+ new_h = rect.height() + dy
+ elif self.position == self.BOTTOM_RIGHT:
+ new_w = rect.width() + dx
+ new_h = rect.height() + dy
+
+ # Enforce minimum size
+ if new_w < min_size:
+ if self.position in (self.TOP_LEFT, self.BOTTOM_LEFT):
+ new_x = pos.x() + rect.width() - min_size
+ new_w = min_size
+ if new_h < min_size:
+ if self.position in (self.TOP_LEFT, self.TOP_RIGHT):
+ new_y = pos.y() + rect.height() - min_size
+ new_h = min_size
+
+ # Apply changes
+ self.parent_item.setPos(new_x, new_y)
+ self.parent_item.setRect(0, 0, new_w, new_h)
+
+ # Update element data
+ self.parent_item._update_element_size(new_w, new_h)
+
+ # Update all handle positions
+ self.parent_item._update_handle_positions()
+
+
+# =============================================================================
+# SVG Element Item - Individual selectable/movable element
+# =============================================================================
+
+class SVGElementItem(QGraphicsRectItem):
+ """A scene item representing an SVG element.
+
+ This is a real QGraphicsItem that Qt can hit-test, select, and transform.
+ Supports: rect, circle, ellipse, line, text, polygon, polyline, path.
+ """
+
+ def __init__(self, element: SVGElement, bounds: QRectF = None,
+ manager: 'SVGSceneManager' = None, parent=None):
+ super().__init__(parent)
+ self.element = element
+ self.element_id = element.id
+ self.manager = manager
+ self._renderer_bounds = bounds # Exact bounds from QSvgRenderer
+
+ # Drag tracking
+ self._dragging = False
+ self._drag_start_pos = None
+
+ # Line local coordinates (relative to item position)
+ self._line_local_x1 = 0.0
+ self._line_local_y1 = 0.0
+ self._line_local_x2 = 0.0
+ self._line_local_y2 = 0.0
+
+ # Polygon/polyline local coordinates
+ self._poly_coords: List[tuple] = []
+
+ # Resize handles (created when selected)
+ self._resize_handles: List[ResizeHandle] = []
+
+ # Configure item flags
+ self.setFlags(
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
+ )
+ self.setAcceptHoverEvents(True)
+
+ # Initialize geometry and appearance
+ self._setup_geometry()
+ self._setup_appearance()
+
+ # -------------------------------------------------------------------------
+ # Setup Methods
+ # -------------------------------------------------------------------------
+
+ def _setup_geometry(self):
+ """Set position and size based on element data.
+
+ If renderer bounds are available, use those for exact positioning.
+ Otherwise fall back to calculating from element attributes.
+ """
+ elem = self.element
+
+ # Use renderer bounds if available (exact positioning)
+ if self._renderer_bounds and not self._renderer_bounds.isEmpty():
+ bounds = self._renderer_bounds
+
+ # IMPORTANT: Set local coords BEFORE setPos, because setPos triggers
+ # itemChange which calls _update_element_position using local coords
+ if elem.element_type == 'line':
+ self._line_local_x1 = elem.x1 - bounds.x()
+ self._line_local_y1 = elem.y1 - bounds.y()
+ self._line_local_x2 = elem.x2 - bounds.x()
+ self._line_local_y2 = elem.y2 - bounds.y()
+ elif elem.element_type in ('polygon', 'polyline') and elem.points:
+ self._setup_polygon_local_coords(bounds.x(), bounds.y())
+
+ # Now safe to set position
+ self.setPos(bounds.x(), bounds.y())
+ self.setRect(0, 0, bounds.width(), bounds.height())
+ return
+
+ # Fallback: calculate positions from element attributes
+ if elem.element_type == 'rect':
+ self.setPos(elem.x, elem.y)
+ self.setRect(0, 0, elem.width, elem.height)
+
+ elif elem.element_type == 'circle':
+ self.setPos(elem.cx - elem.r, elem.cy - elem.r)
+ self.setRect(0, 0, elem.r * 2, elem.r * 2)
+
+ elif elem.element_type == 'ellipse':
+ self.setPos(elem.cx - elem.rx, elem.cy - elem.ry)
+ self.setRect(0, 0, elem.rx * 2, elem.ry * 2)
+
+ elif elem.element_type == 'line':
+ self._setup_line_geometry()
+
+ elif elem.element_type == 'text':
+ self._setup_text_geometry()
+
+ elif elem.element_type in ('polygon', 'polyline'):
+ self._setup_polygon_geometry()
+
+ elif elem.element_type == 'path':
+ self.setPos(elem.x, elem.y)
+ self.setRect(0, 0, elem.width or 100, elem.height or 100)
+
+ else:
+ self.setPos(elem.x, elem.y)
+ self.setRect(0, 0, elem.width or 100, elem.height or 100)
+
+ def _setup_polygon_local_coords(self, offset_x: float, offset_y: float):
+ """Set up polygon local coordinates relative to an offset."""
+ elem = self.element
+ if not elem.points:
+ return
+
+ try:
+ coords = []
+ for pair in elem.points.strip().split():
+ parts = pair.split(',')
+ if len(parts) == 2:
+ coords.append((float(parts[0]), float(parts[1])))
+ self._poly_coords = [(x - offset_x, y - offset_y) for x, y in coords]
+ except (ValueError, IndexError):
+ pass
+
+ def _setup_line_geometry(self):
+ """Set up geometry for line elements."""
+ elem = self.element
+ x = min(elem.x1, elem.x2)
+ y = min(elem.y1, elem.y2)
+ w = max(abs(elem.x2 - elem.x1), 10)
+ h = max(abs(elem.y2 - elem.y1), 10)
+
+ # Store local coordinates BEFORE setPos (setPos triggers itemChange)
+ self._line_local_x1 = elem.x1 - x
+ self._line_local_y1 = elem.y1 - y
+ self._line_local_x2 = elem.x2 - x
+ self._line_local_y2 = elem.y2 - y
+
+ self.setPos(x, y)
+ self.setRect(0, 0, w, h)
+
+ def _setup_text_geometry(self):
+ """Set up geometry for text elements using proper font metrics."""
+ elem = self.element
+
+ # Use QFontMetrics for accurate text measurement
+ font = QFont(elem.font_family, elem.font_size)
+ metrics = QFontMetricsF(font)
+
+ text_width = metrics.horizontalAdvance(elem.text)
+ text_height = metrics.height()
+
+ # SVG text y is the baseline, so position item above the baseline
+ # The ascent is how far above the baseline the text extends
+ ascent = metrics.ascent()
+
+ self.setPos(elem.x, elem.y - ascent)
+ self.setRect(0, 0, max(text_width, 20), text_height)
+
+ def _setup_polygon_geometry(self):
+ """Set up geometry for polygon/polyline elements."""
+ elem = self.element
+ if not elem.points:
+ self.setPos(0, 0)
+ self.setRect(0, 0, 100, 100)
+ return
+
+ try:
+ coords = []
+ for pair in elem.points.strip().split():
+ parts = pair.split(',')
+ if len(parts) == 2:
+ coords.append((float(parts[0]), float(parts[1])))
+
+ if coords:
+ xs = [c[0] for c in coords]
+ ys = [c[1] for c in coords]
+ min_x, max_x = min(xs), max(xs)
+ min_y, max_y = min(ys), max(ys)
+
+ # Store local coords BEFORE setPos
+ self._poly_coords = [(x - min_x, y - min_y) for x, y in coords]
+
+ self.setPos(min_x, min_y)
+ self.setRect(0, 0, max(max_x - min_x, 10), max(max_y - min_y, 10))
+ else:
+ self.setPos(0, 0)
+ self.setRect(0, 0, 100, 100)
+
+ except (ValueError, IndexError):
+ self.setPos(0, 0)
+ self.setRect(0, 0, 100, 100)
+
+ def _setup_appearance(self):
+ """Set colors based on element data."""
+ elem = self.element
+
+ # Fill color
+ if elem.fill and elem.fill != 'none':
+ try:
+ self.setBrush(QBrush(QColor(elem.fill)))
+ except Exception:
+ self.setBrush(QBrush(Qt.GlobalColor.transparent))
+ else:
+ self.setBrush(QBrush(Qt.GlobalColor.transparent))
+
+ # Stroke color
+ if elem.stroke and elem.stroke != 'none':
+ try:
+ pen = QPen(QColor(elem.stroke))
+ pen.setWidthF(elem.stroke_width)
+ self.setPen(pen)
+ except Exception:
+ self.setPen(QPen(Qt.GlobalColor.black))
+ else:
+ self.setPen(QPen(Qt.PenStyle.NoPen))
+
+ # -------------------------------------------------------------------------
+ # Paint Method
+ # -------------------------------------------------------------------------
+
+ def paint(self, painter: QPainter, option: QStyleOptionGraphicsItem, widget: QWidget = None):
+ """Draw the element based on its type."""
+ elem = self.element
+ rect = self.rect()
+
+ painter.setPen(self.pen())
+ painter.setBrush(self.brush())
+
+ if elem.element_type == 'rect':
+ painter.drawRect(rect)
+
+ elif elem.element_type in ('circle', 'ellipse'):
+ painter.drawEllipse(rect)
+
+ elif elem.element_type == 'line':
+ painter.drawLine(
+ QPointF(self._line_local_x1, self._line_local_y1),
+ QPointF(self._line_local_x2, self._line_local_y2)
+ )
+
+ elif elem.element_type == 'text':
+ font = QFont(elem.font_family, elem.font_size)
+ painter.setFont(font)
+ if elem.fill and elem.fill != 'none':
+ painter.setPen(QPen(QColor(elem.fill)))
+ else:
+ painter.setPen(QPen(Qt.GlobalColor.black))
+
+ # Scale text to fit the exact bounding box from the renderer
+ metrics = QFontMetricsF(font)
+ text_width = metrics.horizontalAdvance(elem.text)
+ text_height = metrics.height()
+
+ if text_width > 0 and text_height > 0:
+ scale_x = rect.width() / text_width
+ scale_y = rect.height() / text_height
+ painter.save()
+ painter.scale(scale_x, scale_y)
+ painter.drawText(QPointF(0, metrics.ascent()), elem.text)
+ painter.restore()
+ else:
+ painter.drawText(QPointF(0, metrics.ascent()), elem.text)
+
+ elif elem.element_type == 'polygon' and self._poly_coords:
+ poly = QPolygonF([QPointF(x, y) for x, y in self._poly_coords])
+ painter.drawPolygon(poly)
+
+ elif elem.element_type == 'polyline' and self._poly_coords:
+ poly = QPolygonF([QPointF(x, y) for x, y in self._poly_coords])
+ painter.drawPolyline(poly)
+
+ else:
+ # Fallback: draw bounding rect
+ painter.drawRect(rect)
+
+ # Selection highlight
+ if self.isSelected():
+ painter.setPen(QPen(QColor(0, 120, 215), 2, Qt.PenStyle.DashLine))
+ painter.setBrush(Qt.BrushStyle.NoBrush)
+ painter.drawRect(rect)
+
+ # -------------------------------------------------------------------------
+ # Position/Size Updates
+ # -------------------------------------------------------------------------
+
+ def _update_element_position(self, new_pos: QPointF):
+ """Update element coordinates based on new item position."""
+ elem = self.element
+
+ if elem.element_type == 'rect':
+ elem.x = new_pos.x()
+ elem.y = new_pos.y()
+
+ elif elem.element_type == 'circle':
+ elem.cx = new_pos.x() + elem.r
+ elem.cy = new_pos.y() + elem.r
+
+ elif elem.element_type == 'ellipse':
+ elem.cx = new_pos.x() + elem.rx
+ elem.cy = new_pos.y() + elem.ry
+
+ elif elem.element_type == 'text':
+ # Calculate baseline y from item position
+ font = QFont(elem.font_family, elem.font_size)
+ metrics = QFontMetricsF(font)
+ elem.x = new_pos.x()
+ elem.y = new_pos.y() + metrics.ascent()
+
+ elif elem.element_type == 'line':
+ elem.x1 = new_pos.x() + self._line_local_x1
+ elem.y1 = new_pos.y() + self._line_local_y1
+ elem.x2 = new_pos.x() + self._line_local_x2
+ elem.y2 = new_pos.y() + self._line_local_y2
+
+ elif elem.element_type in ('polygon', 'polyline') and self._poly_coords:
+ new_points = [f"{new_pos.x() + lx},{new_pos.y() + ly}" for lx, ly in self._poly_coords]
+ elem.points = ' '.join(new_points)
+
+ def _update_element_size(self, new_w: float, new_h: float):
+ """Update element size after resize."""
+ elem = self.element
+
+ if elem.element_type == 'rect':
+ elem.width = new_w
+ elem.height = new_h
+
+ elif elem.element_type == 'circle':
+ # Use minimum dimension for radius
+ elem.r = min(new_w, new_h) / 2
+
+ elif elem.element_type == 'ellipse':
+ elem.rx = new_w / 2
+ elem.ry = new_h / 2
+
+ elif elem.element_type == 'line':
+ # Update line endpoints based on new size
+ elem.x2 = elem.x1 + new_w
+ elem.y2 = elem.y1 + new_h
+ # Recalculate local coords
+ self._line_local_x2 = new_w
+ self._line_local_y2 = new_h
+
+ def _create_resize_handles(self):
+ """Create resize handles for this item."""
+ if self._resize_handles:
+ return # Already created
+
+ for pos in (ResizeHandle.TOP_LEFT, ResizeHandle.TOP_RIGHT,
+ ResizeHandle.BOTTOM_LEFT, ResizeHandle.BOTTOM_RIGHT):
+ handle = ResizeHandle(self, pos)
+ self._resize_handles.append(handle)
+
+ def _remove_resize_handles(self):
+ """Remove resize handles."""
+ for handle in self._resize_handles:
+ if handle.scene():
+ handle.scene().removeItem(handle)
+ self._resize_handles.clear()
+
+ def _update_handle_positions(self):
+ """Update positions of all resize handles."""
+ for handle in self._resize_handles:
+ handle.update_position()
+
+ def itemChange(self, change, value):
+ """Handle item changes - update element data when position changes."""
+ if change == QGraphicsItem.GraphicsItemChange.ItemPositionHasChanged:
+ self._update_element_position(value)
+ self._update_handle_positions()
+
+ elif change == QGraphicsItem.GraphicsItemChange.ItemSelectedHasChanged:
+ if value: # Selected
+ self._create_resize_handles()
+ else: # Deselected
+ self._remove_resize_handles()
+
+ return super().itemChange(change, value)
+
+ def mousePressEvent(self, event: QGraphicsSceneMouseEvent):
+ """Handle mouse press - select immediately to enable single-click drag."""
+ if event.button() == Qt.MouseButton.LeftButton:
+ if not self.isSelected():
+ if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
+ self.scene().clearSelection()
+ self.setSelected(True)
+
+ self._dragging = True
+ self._drag_start_pos = self.pos()
+
+ super().mousePressEvent(event)
+
+ def mouseReleaseEvent(self, event: QGraphicsSceneMouseEvent):
+ """Handle mouse release - signal if position changed."""
+ if event.button() == Qt.MouseButton.LeftButton and self._dragging:
+ self._dragging = False
+
+ if self._drag_start_pos and self.pos() != self._drag_start_pos:
+ if self.manager:
+ self.manager.scene_changed.emit()
+
+ self._drag_start_pos = None
+
+ super().mouseReleaseEvent(event)
+
+
+# =============================================================================
+# Background Item
+# =============================================================================
+
+class SVGBackgroundItem(QGraphicsRectItem):
+ """Background rectangle for the SVG canvas."""
+
+ def __init__(self, width: float, height: float, color: str = "#ffffff", parent=None):
+ super().__init__(0, 0, width, height, parent)
+ self.setBrush(QBrush(QColor(color)))
+ self.setPen(QPen(Qt.PenStyle.NoPen))
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable, False)
+ self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, False)
+ self.setZValue(-1000)
+
+
+# =============================================================================
+# SVG Scene Manager
+# =============================================================================
+
+class SVGSceneManager(QObject):
+ """Manages SVG elements in a QGraphicsScene.
+
+ Creates real QGraphicsItems for each SVG element, so Qt handles
+ all hit-testing and coordinate transforms automatically.
+
+ Signals:
+ element_selected: Emitted when selection changes (SVGElement or None)
+ element_moved: Emitted when element position changes
+ scene_changed: Emitted when content changes (need to regenerate SVG)
+ """
+
+ element_selected = pyqtSignal(object)
+ element_moved = pyqtSignal(object, float, float)
+ scene_changed = pyqtSignal()
+
+ def __init__(self, scene: QGraphicsScene, view: QGraphicsView):
+ super().__init__()
+ self.scene = scene
+ self.view = view
+ self.element_items: Dict[str, SVGElementItem] = {}
+ self.background_item: Optional[SVGBackgroundItem] = None
+ self.svg_width = 800.0
+ self.svg_height = 600.0
+ self._enabled = True
+
+ self.scene.selectionChanged.connect(self._on_selection_changed)
+
+ # -------------------------------------------------------------------------
+ # Loading and Parsing
+ # -------------------------------------------------------------------------
+
+ def load_svg(self, svg_source: str):
+ """Parse SVG and create scene items for each element.
+
+ Uses QSvgRenderer.boundsOnElement() to get exact positioning
+ that matches the actual SVG rendering.
+ """
+ # Clear scene
+ self.scene.clear()
+ self.element_items.clear()
+ self.background_item = None
+
+ # Parse dimensions
+ self._parse_dimensions(svg_source)
+
+ # Add IDs to elements that don't have them (needed for boundsOnElement)
+ svg_with_ids = self._add_element_ids(svg_source)
+
+ # Create SVG renderer to get exact element bounds
+ svg_bytes = QByteArray(svg_with_ids.encode('utf-8'))
+ renderer = QSvgRenderer(svg_bytes)
+
+ # Create background
+ self.background_item = SVGBackgroundItem(self.svg_width, self.svg_height, "#f9fafb")
+ self.scene.addItem(self.background_item)
+
+ # Parse and create element items with exact bounds from renderer
+ elements = parse_svg_elements(svg_with_ids)
+ for elem in elements:
+ # Skip background rects (auto-generated ID at 0,0, or matching canvas size)
+ if elem.element_type == 'rect' and elem.x == 0 and elem.y == 0:
+ if elem.id.startswith('el-'):
+ continue
+ if elem.width == self.svg_width and elem.height == self.svg_height:
+ continue
+
+ # Get exact bounds from renderer
+ bounds = None
+ if renderer.isValid() and elem.id:
+ bounds = renderer.boundsOnElement(elem.id)
+ # Check if bounds are valid (non-empty)
+ if bounds.isEmpty():
+ bounds = None
+
+ item = SVGElementItem(elem, bounds=bounds, manager=self)
+ self.scene.addItem(item)
+ self.element_items[elem.id] = item
+
+ # Configure scene rect and view
+ padding = 50
+ self.scene.setSceneRect(
+ -padding, -padding,
+ self.svg_width + padding * 2,
+ self.svg_height + padding * 2
+ )
+ self.view.fitInView(0, 0, self.svg_width, self.svg_height, Qt.AspectRatioMode.KeepAspectRatio)
+
+ def _add_element_ids(self, svg_source: str) -> str:
+ """Add unique IDs to SVG elements that don't have them.
+
+ This allows boundsOnElement() to work for all elements.
+ """
+ import re
+ import uuid
+
+ # Elements we want to be able to select
+ element_types = ['rect', 'circle', 'ellipse', 'line', 'text', 'path', 'polygon', 'polyline']
+
+ result = svg_source
+ for elem_type in element_types:
+ # Find elements of this type without an id attribute
+ # Match ]*\bwidth=["\']([0-9.]+)', svg_source)
+ height_match = re.search(r']*\bheight=["\']([0-9.]+)', svg_source)
+
+ if width_match:
+ try:
+ self.svg_width = float(width_match.group(1))
+ except ValueError:
+ pass
+ if height_match:
+ try:
+ self.svg_height = float(height_match.group(1))
+ except ValueError:
+ pass
+
+ # ViewBox takes precedence
+ vb_match = re.search(r'viewBox=["\']([^"\']+)["\']', svg_source)
+ if vb_match:
+ parts = vb_match.group(1).split()
+ if len(parts) == 4:
+ try:
+ self.svg_width = float(parts[2])
+ self.svg_height = float(parts[3])
+ except ValueError:
+ pass
+
+ # -------------------------------------------------------------------------
+ # Selection
+ # -------------------------------------------------------------------------
+
+ def _on_selection_changed(self):
+ """Handle scene selection change."""
+ try:
+ selected = self.scene.selectedItems()
+ if selected and isinstance(selected[0], SVGElementItem):
+ self.element_selected.emit(selected[0].element)
+ else:
+ self.element_selected.emit(None)
+ except RuntimeError:
+ # Scene has been deleted (app closing)
+ pass
+
+ def select_element_by_id(self, element_id: str):
+ """Select an element by its ID."""
+ try:
+ self.scene.clearSelection()
+ if element_id in self.element_items:
+ self.element_items[element_id].setSelected(True)
+ except RuntimeError:
+ pass
+
+ def get_selected_element(self) -> Optional[SVGElement]:
+ """Get the currently selected element."""
+ try:
+ selected = self.scene.selectedItems()
+ if selected and isinstance(selected[0], SVGElementItem):
+ return selected[0].element
+ except RuntimeError:
+ pass
+ return None
+
+ def clear_selection(self):
+ """Clear the current selection."""
+ try:
+ self.scene.clearSelection()
+ except RuntimeError:
+ pass
+
+ # -------------------------------------------------------------------------
+ # SVG Generation
+ # -------------------------------------------------------------------------
+
+ def generate_svg(self) -> str:
+ """Generate SVG source from current scene items."""
+ lines = [
+ '',
+ f'',
+ ' ',
+ ]
+
+ for item in self.element_items.values():
+ svg_elem = self._element_to_svg(item.element)
+ if svg_elem:
+ lines.append(f' {svg_elem}')
+
+ lines.append('')
+ return '\n'.join(lines)
+
+ def _element_to_svg(self, elem: SVGElement) -> str:
+ """Convert an SVGElement to SVG markup."""
+ attrs = f'id="{elem.id}"'
+
+ if elem.fill and elem.fill != 'none':
+ attrs += f' fill="{elem.fill}"'
+ if elem.stroke and elem.stroke != 'none':
+ attrs += f' stroke="{elem.stroke}"'
+ if elem.stroke_width != 1:
+ attrs += f' stroke-width="{elem.stroke_width}"'
+
+ if elem.element_type == 'rect':
+ return f''
+ elif elem.element_type == 'circle':
+ return f''
+ elif elem.element_type == 'ellipse':
+ return f''
+ elif elem.element_type == 'line':
+ return f''
+ elif elem.element_type == 'text':
+ return f'{elem.text}'
+ elif elem.element_type == 'path':
+ return f''
+ elif elem.element_type == 'polygon':
+ return f''
+ elif elem.element_type == 'polyline':
+ return f''
+
+ return ''
+
+ # -------------------------------------------------------------------------
+ # Element Operations
+ # -------------------------------------------------------------------------
+
+ def delete_selected(self):
+ """Delete the currently selected element."""
+ try:
+ selected = self.scene.selectedItems()
+ if selected and isinstance(selected[0], SVGElementItem):
+ item = selected[0]
+ if item.element_id in self.element_items:
+ del self.element_items[item.element_id]
+ self.scene.removeItem(item)
+ self.scene_changed.emit()
+ except RuntimeError:
+ pass
+
+ def get_elements(self) -> List[SVGElement]:
+ """Get all elements in the scene."""
+ return [item.element for item in self.element_items.values()]
+
+ # -------------------------------------------------------------------------
+ # Enable/Disable
+ # -------------------------------------------------------------------------
+
+ def set_enabled(self, enabled: bool):
+ """Enable or disable selection/interaction."""
+ self._enabled = enabled
+ flags = (
+ QGraphicsItem.GraphicsItemFlag.ItemIsSelectable |
+ QGraphicsItem.GraphicsItemFlag.ItemIsMovable |
+ QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
+ ) if enabled else QGraphicsItem.GraphicsItemFlag(0)
+
+ for item in self.element_items.values():
+ item.setFlags(flags)
+
+ def is_enabled(self) -> bool:
+ """Check if selection is enabled."""
+ return self._enabled