feat: Add scene-based SVG editing with exact element positioning

- Use QSvgRenderer.boundsOnElement() for pixel-perfect element positioning
- Add SVGSceneManager with QGraphicsItems for selection, drag, and resize
- Auto-add IDs to elements without them to enable bounds lookup
- Fix line rendering by setting local coords before setPos()
- Scale text to fit exact renderer bounds for accurate display
- Support rect, circle, ellipse, line, text, polygon, polyline, path elements
- Add resize handles on corners when element is selected
- Sync selection between canvas and elements list

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-23 05:00:32 -04:00
parent 1ac0b181e1
commit a8cc90c128
3 changed files with 2558 additions and 45 deletions

View File

@ -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: <svg xmlns="http://www.w3.org/2000/svg" viewBox="x y w h">
- Shapes: <rect x="" y="" width="" height="" fill="" stroke=""/>
- Circle: <circle cx="" cy="" r="" fill=""/>
- Ellipse: <ellipse cx="" cy="" rx="" ry=""/>
- Line: <line x1="" y1="" x2="" y2="" stroke=""/>
- Text: <text x="" y="" font-size="" fill="">content</text>
- Path: <path d="M x y L x y C x1 y1 x2 y2 x y Z"/>
- Groups: <g transform="translate(x,y)">...</g>
Example:
```svg
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300" width="400" height="300">
<rect x="50" y="50" width="100" height="60" fill="#3b82f6" stroke="#1e40af" stroke-width="2"/>
<circle cx="250" cy="80" r="40" fill="#10b981"/>
<text x="200" y="200" font-size="24" text-anchor="middle" fill="#333">Hello SVG</text>
</svg>
```""",
'excalidraw': """You are an Excalidraw JSON expert. Generate valid Excalidraw JSON.
Excalidraw format:
- JSON object with "type": "excalidraw", "version": 2, "elements": []
- Each element needs: type, id (8 char), x, y, width, height
- Element types: rectangle, ellipse, diamond, line, arrow, text
- Colors: strokeColor, backgroundColor (hex or "transparent")
- For text: include "text", "fontSize", "fontFamily" (1=hand-drawn, 2=normal, 3=mono)
- For lines/arrows: include "points" array like [[0,0], [100,50]]
Example:
```json
{
"type": "excalidraw",
"version": 2,
"elements": [
{
"type": "rectangle",
"id": "rect001",
"x": 100,
"y": 100,
"width": 200,
"height": 100,
"strokeColor": "#1e88e5",
"backgroundColor": "#e3f2fd",
"strokeWidth": 1,
"roughness": 1,
"fillStyle": "hachure"
},
{
"type": "text",
"id": "text001",
"x": 150,
"y": 140,
"width": 100,
"height": 25,
"text": "Hello",
"fontSize": 20,
"fontFamily": 1,
"strokeColor": "#1e88e5"
}
]
}
```"""
}
class AIThread(QThread):
"""Background thread for AI generation via SmartTools."""
"""Background thread for AI generation with validation and retry."""
finished = pyqtSignal(bool, str) # success, result/error
status_update = pyqtSignal(str) # status message for UI
MAX_RETRIES = 2
def __init__(self, instruction: str, current_code: str, artifact_type: str):
super().__init__()
@ -204,11 +370,112 @@ class AIThread(QThread):
self.current_code = current_code
self.artifact_type = artifact_type
def run(self):
try:
# Try discussion-diagram-editor SmartTool first
tool_path = Path.home() / ".local" / "bin" / "discussion-diagram-editor"
def _get_renderer(self):
"""Get the renderer for validation."""
return get_renderer(self.artifact_type)
def _validate_output(self, code: str) -> tuple:
"""Validate the generated code. Returns (is_valid, error_message)."""
renderer = self._get_renderer()
if renderer:
return renderer.validate(code)
return True, None # No validation available
def _build_prompt(self, retry_error: str = None, previous_output: str = None) -> str:
"""Build the AI prompt, optionally including retry context."""
format_guide = AI_PROMPTS.get(self.artifact_type, f"You are a {self.artifact_type} expert.")
# Format-specific output instructions
format_instructions = {
'plantuml': "Output ONLY valid PlantUML code starting with @startuml and ending with @enduml.",
'mermaid': "Output ONLY valid Mermaid code starting with the diagram type (graph, sequenceDiagram, etc.).",
'openscad': "Output ONLY valid OpenSCAD code with proper syntax.",
'svg': "Output ONLY valid SVG code starting with <?xml or <svg and ending with </svg>. Do NOT wrap in any other format.",
'excalidraw': "Output ONLY valid Excalidraw JSON starting with { and ending with }. Do NOT wrap in any other format.",
'code': "Output ONLY the code, no markdown fences or explanations.",
}
output_instruction = format_instructions.get(self.artifact_type, "Output ONLY the code, no explanations.")
if retry_error and previous_output:
# Retry prompt with error feedback
return f"""{format_guide}
CRITICAL: {output_instruction}
Original request: {self.instruction}
Current code in editor:
```
{self.current_code}
```
Your previous output:
```
{previous_output}
```
However, validation failed with this error:
{retry_error}
Please fix the error and provide corrected output. {output_instruction}"""
else:
# Initial prompt
return f"""{format_guide}
CRITICAL: {output_instruction}
Current code:
```
{self.current_code}
```
User request: {self.instruction}
{output_instruction}"""
def _extract_code(self, response: str) -> str:
"""Extract code from AI response, handling markdown fences and errant wrappers."""
code = response.strip()
# Remove markdown code fences
if "```" in code:
match = re.search(r'```(?:\w+)?\n(.*?)```', code, re.DOTALL)
if match:
code = match.group(1).strip()
# For SVG: extract just the SVG content if wrapped in other formats
if self.artifact_type == 'svg':
# Remove any @startuml/@enduml wrappers
code = re.sub(r'^@startuml\s*\n?', '', code)
code = re.sub(r'\n?@enduml\s*$', '', code)
# Find the SVG content
svg_match = re.search(r'(<\?xml[^?]*\?>)?\s*(<svg[\s\S]*?</svg>)', code)
if svg_match:
xml_decl = svg_match.group(1) or ''
svg_content = svg_match.group(2)
code = (xml_decl + '\n' + svg_content).strip() if xml_decl else svg_content
# For Excalidraw: extract just the JSON
elif self.artifact_type == 'excalidraw':
# Find JSON object
json_match = re.search(r'\{[\s\S]*\}', code)
if json_match:
code = json_match.group(0)
# For PlantUML: ensure it has proper markers
elif self.artifact_type == 'plantuml':
if not code.strip().startswith('@start'):
code = '@startuml\n' + code
if not code.strip().endswith('@enduml') and '@enduml' not in code:
code = code + '\n@enduml'
return code.strip()
def _call_ai(self, prompt: str) -> tuple:
"""Call AI and return (success, response_or_error)."""
# Only use discussion-diagram-editor for PlantUML (it's PlantUML-specific)
if self.artifact_type == 'plantuml':
tool_path = Path.home() / ".local" / "bin" / "discussion-diagram-editor"
if tool_path.exists():
result = subprocess.run(
[str(tool_path), "--instruction", self.instruction],
@ -218,41 +485,70 @@ class AIThread(QThread):
timeout=60
)
if result.returncode == 0:
self.finished.emit(True, result.stdout)
return
return True, result.stdout
# Fallback: try claude directly
if subprocess.run(["which", "claude"], capture_output=True).returncode == 0:
prompt = f"""You are a {self.artifact_type} diagram expert.
# Use claude directly for all formats
if subprocess.run(["which", "claude"], capture_output=True).returncode == 0:
result = subprocess.run(
["claude", "-p", "--model", "sonnet"],
input=prompt,
capture_output=True,
text=True,
timeout=120
)
Current diagram code:
```
{self.current_code}
```
if result.returncode == 0:
return True, result.stdout
User request: {self.instruction}
return False, "No AI tools available (claude CLI not found)"
Respond with ONLY the updated diagram code, no explanations."""
def run(self):
try:
retry_count = 0
last_error = None
last_output = None
result = subprocess.run(
["claude", "-p", "--model", "haiku"],
input=prompt,
capture_output=True,
text=True,
timeout=60
while retry_count <= self.MAX_RETRIES:
# Build prompt (with error context if retrying)
prompt = self._build_prompt(
retry_error=last_error if retry_count > 0 else None,
previous_output=last_output if retry_count > 0 else None
)
if result.returncode == 0:
# Extract code from response
code = result.stdout.strip()
if "```" in code:
match = re.search(r'```(?:\w+)?\n(.*?)```', code, re.DOTALL)
if match:
code = match.group(1).strip()
self.finished.emit(True, code)
if retry_count > 0:
self.status_update.emit(f"Retry {retry_count}/{self.MAX_RETRIES}: fixing validation error...")
# Call AI
success, response = self._call_ai(prompt)
if not success:
self.finished.emit(False, response)
return
self.finished.emit(False, "No AI tools available (tried discussion-diagram-editor, claude)")
# Extract code from response
code = self._extract_code(response)
# Validate output
is_valid, error = self._validate_output(code)
if is_valid:
self.finished.emit(True, code)
return
else:
# Validation failed - prepare for retry
last_error = error
last_output = code
retry_count += 1
if retry_count > self.MAX_RETRIES:
# Max retries exceeded - return the code anyway with warning
self.finished.emit(
True,
f"<!-- Warning: Validation failed after {self.MAX_RETRIES} retries: {error} -->\n{code}"
if self.artifact_type in ('svg', 'code')
else code
)
return
except subprocess.TimeoutExpired:
self.finished.emit(False, "AI request timed out")
@ -299,6 +595,7 @@ class DiagramCanvas(QGraphicsView):
rotation_changed = pyqtSignal(int, int) # Emitted for 3D rotation (delta_x, delta_y)
zoom_changed = pyqtSignal(int) # Emitted for zoom change (delta)
element_clicked = pyqtSignal(str) # Emitted when an element is clicked (element name)
view_transform_changed = pyqtSignal() # Emitted when view transform changes (zoom)
def __init__(self, parent=None):
super().__init__(parent)
@ -554,6 +851,7 @@ class DiagramCanvas(QGraphicsView):
# Normal mode: scale the view
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
self.scale(factor, factor)
self.view_transform_changed.emit()
event.accept()
elif event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
# Shift+wheel for horizontal scrolling
@ -722,6 +1020,8 @@ class ArtifactEditorWindow(QMainWindow):
# Initial render
self._schedule_render()
# SVG selection will be enabled after first render in _update_svg_overlay
# Show getting started for new diagrams
if not initial_content or initial_content.strip() == self.renderer.get_template().strip():
# Use a timer to show after window is displayed
@ -976,6 +1276,10 @@ class ArtifactEditorWindow(QMainWindow):
)
right_layout.addWidget(self.canvas)
# SVG selection manager (for SVG format)
# Uses scene-based graphics items - Qt handles hit-testing automatically
self.svg_selection = SVGSceneManager(self.canvas.scene, self.canvas)
# 3D rotation controls (for OpenSCAD)
self.rotation_panel = QWidget()
rotation_layout = QHBoxLayout(self.rotation_panel)
@ -1028,6 +1332,90 @@ class ArtifactEditorWindow(QMainWindow):
self.rotation_panel.setVisible(False) # Hidden by default
right_layout.addWidget(self.rotation_panel)
# Code settings panel (for code/syntax highlighting format)
self.code_panel = QWidget()
code_layout = QHBoxLayout(self.code_panel)
code_layout.setContentsMargins(4, 4, 4, 4)
code_layout.addWidget(QLabel("Language:"))
self.language_combo = QComboBox()
self.language_combo.setMaximumWidth(150)
self.language_combo.setToolTip("Select the programming language for syntax highlighting")
# Populate with languages
for lang_id, lang_name in get_languages():
self.language_combo.addItem(lang_name, lang_id)
self.language_combo.setCurrentIndex(0) # Default to first (Python)
self.language_combo.currentIndexChanged.connect(self._on_code_settings_changed)
self.language_combo.setStyleSheet("""
QComboBox {
background-color: #3a3a3a;
color: #ffffff;
padding: 4px 8px;
border: 1px solid #555;
border-radius: 3px;
}
QComboBox:hover { border-color: #4a9eff; }
QComboBox::drop-down { border: none; width: 20px; }
QComboBox::down-arrow {
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #ffffff;
}
QComboBox QAbstractItemView {
background-color: #2d2d2d;
color: #ffffff;
selection-background-color: #4a9eff;
}
""")
code_layout.addWidget(self.language_combo)
code_layout.addWidget(QLabel("Theme:"))
self.theme_combo = QComboBox()
self.theme_combo.setMaximumWidth(150)
self.theme_combo.setToolTip("Select the color theme for syntax highlighting")
for theme in get_themes():
self.theme_combo.addItem(theme)
# Set default theme to monokai
monokai_idx = self.theme_combo.findText("monokai")
if monokai_idx >= 0:
self.theme_combo.setCurrentIndex(monokai_idx)
self.theme_combo.currentIndexChanged.connect(self._on_code_settings_changed)
self.theme_combo.setStyleSheet(self.language_combo.styleSheet())
code_layout.addWidget(self.theme_combo)
code_layout.addWidget(QLabel("Font:"))
self.font_size_spin = QSpinBox()
self.font_size_spin.setRange(8, 32)
self.font_size_spin.setValue(14)
self.font_size_spin.setSuffix("px")
self.font_size_spin.setMaximumWidth(70)
self.font_size_spin.setToolTip("Font size for code display")
self.font_size_spin.valueChanged.connect(self._on_code_settings_changed)
self.font_size_spin.setStyleSheet("""
QSpinBox {
background-color: #3a3a3a;
color: #ffffff;
padding: 4px;
border: 1px solid #555;
border-radius: 3px;
}
QSpinBox:hover { border-color: #4a9eff; }
""")
code_layout.addWidget(self.font_size_spin)
self.line_numbers_check = QCheckBox("Line Numbers")
self.line_numbers_check.setChecked(True)
self.line_numbers_check.setToolTip("Show line numbers in the rendered output")
self.line_numbers_check.stateChanged.connect(self._on_code_settings_changed)
self.line_numbers_check.setStyleSheet("QCheckBox { color: #d4d4d4; }")
code_layout.addWidget(self.line_numbers_check)
code_layout.addStretch()
self.code_panel.setStyleSheet("background-color: #333; color: #d4d4d4;")
self.code_panel.setVisible(False) # Hidden by default
right_layout.addWidget(self.code_panel)
self.splitter.addWidget(right_panel)
# Set initial split ratio (50/50)
@ -1243,6 +1631,19 @@ class ArtifactEditorWindow(QMainWindow):
self.canvas.zoom_changed.connect(self._on_canvas_zoom)
self.canvas.element_clicked.connect(self._on_element_clicked)
# SVG selection manager signals (scene-based approach)
self.svg_selection.element_selected.connect(self._on_svg_overlay_element_selected)
self.svg_selection.element_moved.connect(self._on_svg_overlay_element_moved)
self.svg_selection.scene_changed.connect(self._on_svg_scene_changed)
# Elements list selection sync
self.elements_list.itemClicked.connect(self._on_elements_list_clicked)
def eventFilter(self, obj, event):
"""Handle events from filtered objects."""
# No special handling needed - scene-based selection handles scroll/zoom automatically
return super().eventFilter(obj, event)
def _set_state(self, state: EditorState):
"""Update editor state machine."""
self.state = state
@ -1297,6 +1698,14 @@ class ArtifactEditorWindow(QMainWindow):
self.rotation_panel.setVisible(is_openscad)
self.canvas.set_rotation_mode(is_openscad)
# Show/hide code settings panel
is_code = artifact_type == "code"
self.code_panel.setVisible(is_code)
# Enable/disable SVG selection
is_svg = artifact_type == "svg"
self.svg_selection.set_enabled(is_svg)
# Rebuild templates menu for this format
self._rebuild_templates_menu()
@ -1363,8 +1772,12 @@ class ArtifactEditorWindow(QMainWindow):
# Create temp file for output
# OpenSCAD 3D models must render to PNG (SVG is 2D only)
# Mermaid SVGs use foreignObject for text which Qt can't render, so use PNG
if self.artifact_type in ("openscad", "mermaid"):
# Code syntax highlighting renders to PNG for reliable display
# SVG and Excalidraw render to SVG (which Qt can display natively)
if self.artifact_type in ("openscad", "mermaid", "code"):
suffix = ".png"
elif self.artifact_type in ("svg", "excalidraw"):
suffix = ".svg"
else:
suffix = ".svg"
temp_output = Path(tempfile.mktemp(suffix=suffix))
@ -1380,7 +1793,19 @@ class ArtifactEditorWindow(QMainWindow):
self.zoom_slider.value()
)
self.render_thread = RenderThread(self.renderer, source, temp_output, camera_angles)
# Get code settings for syntax highlighting
code_options = None
if self.artifact_type == "code":
code_options = {
'language': self.language_combo.currentData() or 'python',
'theme': self.theme_combo.currentText() or 'monokai',
'line_numbers': self.line_numbers_check.isChecked(),
'font_size': self.font_size_spin.value(),
}
self.render_thread = RenderThread(
self.renderer, source, temp_output, camera_angles, code_options
)
self.render_thread.finished.connect(self._on_render_finished)
self.render_thread.start()
@ -1389,6 +1814,10 @@ class ArtifactEditorWindow(QMainWindow):
if success:
self.canvas.show_preview(message)
self._set_state(EditorState.PREVIEW)
# Update SVG overlay if this is an SVG artifact
if self.artifact_type == "svg":
self._update_svg_overlay()
else:
self.canvas.show_error(message)
self._set_state(EditorState.ERROR)
@ -1403,11 +1832,17 @@ class ArtifactEditorWindow(QMainWindow):
current_code = self.code_editor.toPlainText()
self.ai_thread = AIThread(instruction, current_code, self.artifact_type)
self.ai_thread.finished.connect(self._on_ai_finished)
self.ai_thread.status_update.connect(self._on_ai_status)
self.ai_thread.start()
def _on_ai_status(self, status: str):
"""Handle AI status updates (e.g., retry messages)."""
self.render_status_label.setText(f"AI: {status}")
def _on_ai_finished(self, success: bool, result: str):
"""Handle AI generation completion."""
self.ai_panel.set_ai_processing(False)
self._set_state(EditorState.IDLE)
if success:
self.code_editor.setPlainText(result)
@ -1483,6 +1918,22 @@ class ArtifactEditorWindow(QMainWindow):
item.setData(Qt.ItemDataRole.UserRole, element)
self.elements_list.addItem(item)
elif self.artifact_type == "excalidraw":
# Parse Excalidraw elements
elements = parse_excalidraw_elements(code)
for element in elements:
item = QListWidgetItem(element.get_label())
item.setData(Qt.ItemDataRole.UserRole, element)
self.elements_list.addItem(item)
elif self.artifact_type == "svg":
# Parse SVG elements
elements = parse_svg_elements(code)
for element in elements:
item = QListWidgetItem(element.get_label())
item.setData(Qt.ItemDataRole.UserRole, element)
self.elements_list.addItem(item)
def _on_diagram_type_changed(self, diagram_type: str):
"""Handle diagram type selection change."""
self.detected_diagram_type = diagram_type
@ -1490,22 +1941,40 @@ class ArtifactEditorWindow(QMainWindow):
def _update_elements_panel_for_format(self):
"""Update elements panel UI for the current artifact format."""
if self.artifact_type == "plantuml":
self.elements_group.setVisible(True)
self.elements_group.setTitle("Diagram Elements")
self.add_element_btn.setText("+ Element")
self.add_rel_btn.setText("+ Relationship")
self.add_rel_btn.setVisible(True)
self.diagram_type_combo.setVisible(True)
elif self.artifact_type == "mermaid":
self.elements_group.setVisible(True)
self.elements_group.setTitle("Diagram Elements")
self.add_element_btn.setText("+ Node")
self.add_rel_btn.setText("+ Connection")
self.add_rel_btn.setVisible(True)
self.diagram_type_combo.setVisible(False)
elif self.artifact_type == "openscad":
self.elements_group.setVisible(True)
self.elements_group.setTitle("OpenSCAD Elements")
self.add_element_btn.setText("+ Element")
self.add_rel_btn.setVisible(False) # OpenSCAD doesn't have relationships
self.diagram_type_combo.setVisible(False)
elif self.artifact_type == "code":
# Code format doesn't have elements panel
self.elements_group.setVisible(False)
elif self.artifact_type == "svg":
self.elements_group.setVisible(True)
self.elements_group.setTitle("SVG Elements")
self.add_element_btn.setText("+ Shape")
self.add_rel_btn.setVisible(False) # SVG doesn't have relationships
self.diagram_type_combo.setVisible(False)
elif self.artifact_type == "excalidraw":
self.elements_group.setVisible(True)
self.elements_group.setTitle("Excalidraw Elements")
self.add_element_btn.setText("+ Shape")
self.add_rel_btn.setVisible(False) # Excalidraw doesn't have relationships
self.diagram_type_combo.setVisible(False)
else:
# Default/unknown format
self.elements_group.setVisible(False)
@ -1566,6 +2035,22 @@ class ArtifactEditorWindow(QMainWindow):
)
self.code_editor.setPlainText(new_code)
elif self.artifact_type == "excalidraw":
dialog = AddExcalidrawElementDialog(parent=self)
if dialog.exec():
element = dialog.get_element()
if element:
new_code = add_excalidraw_element(code, element)
self.code_editor.setPlainText(new_code)
elif self.artifact_type == "svg":
dialog = AddSVGElementDialog(parent=self)
if dialog.exec():
element = dialog.get_element()
if element:
new_code = add_svg_element(code, element)
self.code_editor.setPlainText(new_code)
self._refresh_elements_list()
def _on_add_relationship(self):
@ -1654,6 +2139,23 @@ class ArtifactEditorWindow(QMainWindow):
code = replace_mermaid_element(code, element, new_code)
self.code_editor.setPlainText(code)
elif self.artifact_type == "excalidraw":
dialog = EditExcalidrawElementDialog(element, parent=self)
if dialog.exec():
updated_element = dialog.get_element()
if updated_element:
new_code = update_excalidraw_element(code, element.id, updated_element)
self.code_editor.setPlainText(new_code)
elif self.artifact_type == "svg":
dialog = EditSVGElementDialog(element, parent=self)
if dialog.exec():
updated_element = dialog.get_element()
if updated_element:
# Pass original element to avoid ID mismatch from re-parsing
new_code = update_svg_element(code, element.id, updated_element, original_element=element)
self.code_editor.setPlainText(new_code)
def _on_delete_element(self):
"""Delete selected element from diagram."""
current_item = self.elements_list.currentItem()
@ -1689,6 +2191,11 @@ class ArtifactEditorWindow(QMainWindow):
new_code = delete_mermaid_element(code, element.id)
elif self.artifact_type == "openscad":
new_code = delete_openscad_element(code, element.name)
elif self.artifact_type == "excalidraw":
new_code = delete_excalidraw_element(code, element.id)
elif self.artifact_type == "svg":
# Pass original element to avoid ID mismatch from re-parsing
new_code = delete_svg_element(code, element.id, original_element=element)
else:
new_code = code
@ -1701,6 +2208,11 @@ class ArtifactEditorWindow(QMainWindow):
if self.artifact_type == "openscad":
self._schedule_render(200) # Short debounce for smooth interaction
def _on_code_settings_changed(self):
"""Handle code settings changes - re-render with new settings."""
if self.artifact_type == "code":
self._schedule_render(200) # Short debounce
def _reset_rotation(self):
"""Reset rotation sliders to default OpenSCAD view."""
# Block signals to prevent multiple renders
@ -2043,6 +2555,191 @@ class ArtifactEditorWindow(QMainWindow):
self.output_path = Path(path)
self._save()
# --- SVG Selection Methods ---
def _update_svg_overlay(self):
"""Update the SVG selection manager with current SVG content.
Uses the scene-based approach: SVG elements become real QGraphicsItems
that Qt can hit-test, select, and transform automatically.
"""
if self.artifact_type != "svg":
return
source = self.code_editor.toPlainText()
# Load SVG into scene manager - it handles all parsing and creates scene items
self.svg_selection.load_svg(source)
self.svg_selection.set_enabled(True)
# Update elements list so selection sync works
self._update_elements_list()
def _on_svg_scene_changed(self):
"""Handle scene changes from the SVG manager (element moved, deleted, etc.)."""
# Regenerate SVG from the scene and update code editor
# Block signals to prevent triggering a re-render
new_svg = self.svg_selection.generate_svg()
self.code_editor.blockSignals(True)
self.code_editor.setPlainText(new_svg)
self.code_editor.blockSignals(False)
def _on_svg_overlay_element_selected(self, element):
"""Handle element selection from overlay."""
if element is None:
self.elements_list.clearSelection()
return
# Find and select matching element in the list
for i in range(self.elements_list.count()):
item = self.elements_list.item(i)
el = item.data(Qt.ItemDataRole.UserRole)
if hasattr(el, 'id') and el.id == element.id:
self.elements_list.setCurrentItem(item)
break
def _on_svg_overlay_element_moved(self, element, new_x: float, new_y: float):
"""Handle element move from overlay."""
if not element:
return
# Create updated element with new position
updated = SVGElement(
element_type=element.element_type,
id=element.id,
x=new_x,
y=new_y,
width=element.width,
height=element.height,
cx=new_x + element.width / 2 if element.element_type in ('circle', 'ellipse') else element.cx,
cy=new_y + element.height / 2 if element.element_type in ('circle', 'ellipse') else element.cy,
r=element.r,
rx=element.rx,
ry=element.ry,
x1=new_x if element.element_type == 'line' else element.x1,
y1=new_y if element.element_type == 'line' else element.y1,
x2=new_x + (element.x2 - element.x1) if element.element_type == 'line' else element.x2,
y2=new_y + (element.y2 - element.y1) if element.element_type == 'line' else element.y2,
fill=element.fill,
stroke=element.stroke,
stroke_width=element.stroke_width,
text=element.text,
font_size=element.font_size,
font_family=element.font_family,
d=element.d,
points=element.points,
)
# Update the SVG source
code = self.code_editor.toPlainText()
new_code = update_svg_element(code, element.id, updated, element)
self.code_editor.setPlainText(new_code)
def _on_svg_overlay_element_resized(self, element, x: float, y: float, w: float, h: float):
"""Handle element resize from overlay."""
if not element:
return
# Create updated element with new size
updated = SVGElement(
element_type=element.element_type,
id=element.id,
x=x,
y=y,
width=w,
height=h,
cx=x + w / 2 if element.element_type in ('circle', 'ellipse') else element.cx,
cy=y + h / 2 if element.element_type in ('circle', 'ellipse') else element.cy,
r=w / 2 if element.element_type == 'circle' else element.r,
rx=w / 2 if element.element_type == 'ellipse' else element.rx,
ry=h / 2 if element.element_type == 'ellipse' else element.ry,
x1=x if element.element_type == 'line' else element.x1,
y1=y if element.element_type == 'line' else element.y1,
x2=x + w if element.element_type == 'line' else element.x2,
y2=y + h if element.element_type == 'line' else element.y2,
fill=element.fill,
stroke=element.stroke,
stroke_width=element.stroke_width,
text=element.text,
font_size=element.font_size,
font_family=element.font_family,
d=element.d,
points=element.points,
)
# Update the SVG source
code = self.code_editor.toPlainText()
new_code = update_svg_element(code, element.id, updated, element)
self.code_editor.setPlainText(new_code)
def _on_elements_list_clicked(self, item):
"""Handle elements list click - sync selection to overlay."""
if self.artifact_type != "svg":
return
element = item.data(Qt.ItemDataRole.UserRole)
if element and hasattr(element, 'id'):
self.svg_selection.select_element_by_id(element.id)
def _on_svg_bring_to_front(self, element):
"""Move SVG element to front (top of z-order)."""
code = self.code_editor.toPlainText()
new_code = bring_element_to_front(code, element)
self.code_editor.setPlainText(new_code)
def _on_svg_send_to_back(self, element):
"""Move SVG element to back (bottom of z-order)."""
code = self.code_editor.toPlainText()
new_code = send_element_to_back(code, element)
self.code_editor.setPlainText(new_code)
def _on_svg_bring_forward(self, element):
"""Move SVG element one step forward in z-order."""
code = self.code_editor.toPlainText()
new_code = move_element_forward(code, element)
self.code_editor.setPlainText(new_code)
def _on_svg_send_backward(self, element):
"""Move SVG element one step backward in z-order."""
code = self.code_editor.toPlainText()
new_code = move_element_backward(code, element)
self.code_editor.setPlainText(new_code)
def _on_svg_add_to_group(self, element, group_id):
"""Add SVG element to an existing group."""
code = self.code_editor.toPlainText()
new_code = add_to_group(code, element, group_id)
self.code_editor.setPlainText(new_code)
def _on_svg_remove_from_group(self, element):
"""Remove SVG element from its parent group."""
code = self.code_editor.toPlainText()
new_code = remove_from_group(code, element)
self.code_editor.setPlainText(new_code)
def _on_svg_create_group(self, element):
"""Create a new group containing the SVG element."""
from PyQt6.QtWidgets import QInputDialog
group_id, ok = QInputDialog.getText(
self,
"Create Group",
"Enter group ID:",
text=f"group-{element.id[:8]}"
)
if ok and group_id:
code = self.code_editor.toPlainText()
new_code = create_group(code, element, group_id)
self.code_editor.setPlainText(new_code)
def _on_svg_delete_element(self, element):
"""Delete an SVG element."""
code = self.code_editor.toPlainText()
new_code = delete_svg_element(code, element.id, element)
self.code_editor.setPlainText(new_code)
self.svg_selection.clear_selection()
def closeEvent(self, event):
"""Handle window close."""
if self.is_modified:

View File

@ -0,0 +1,953 @@
"""Parser for SVG format with element extraction and manipulation."""
import re
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Tuple
@dataclass
class SVGElement:
"""Represents an SVG element."""
element_type: str # rect, circle, ellipse, line, text, path, polygon, polyline
id: str
x: float = 0
y: float = 0
width: float = 100
height: float = 100
# Circle/ellipse specific
cx: float = 0
cy: float = 0
r: float = 50 # circle radius
rx: float = 50 # ellipse x radius
ry: float = 50 # ellipse y radius
# Line specific
x1: float = 0
y1: float = 0
x2: float = 100
y2: float = 100
# Style
fill: str = "none"
stroke: str = "#000000"
stroke_width: float = 1
opacity: float = 1.0
# Text specific
text: str = ""
font_size: int = 16
font_family: str = "sans-serif"
text_anchor: str = "start" # start, middle, end
# Path/polygon specific
points: str = "" # For polygon/polyline
d: str = "" # For path
# Group membership
parent_group: str = "" # ID of parent <g> element, if any
# For tracking position in source
raw_xml: str = ""
def get_label(self) -> str:
"""Get a display label for this element."""
if self.element_type == 'text':
preview = self.text[:20] + ('...' if len(self.text) > 20 else '')
return f"Text: {preview}"
elif self.element_type == 'circle':
return f"Circle (r={self.r:.0f})"
elif self.element_type == 'ellipse':
return f"Ellipse ({self.rx:.0f}x{self.ry:.0f})"
elif self.element_type == 'line':
return f"Line ({self.x1:.0f},{self.y1:.0f})->({self.x2:.0f},{self.y2:.0f})"
elif self.element_type == 'rect':
return f"Rect ({self.width:.0f}x{self.height:.0f})"
elif self.element_type == 'path':
return f"Path"
elif self.element_type in ('polygon', 'polyline'):
return f"{self.element_type.title()}"
else:
return f"{self.element_type.title()}"
def to_xml_element(self) -> ET.Element:
"""Convert to an XML Element."""
el = ET.Element(self.element_type)
if self.id:
el.set('id', self.id)
if self.element_type == 'rect':
el.set('x', str(self.x))
el.set('y', str(self.y))
el.set('width', str(self.width))
el.set('height', str(self.height))
elif self.element_type == 'circle':
el.set('cx', str(self.cx))
el.set('cy', str(self.cy))
el.set('r', str(self.r))
elif self.element_type == 'ellipse':
el.set('cx', str(self.cx))
el.set('cy', str(self.cy))
el.set('rx', str(self.rx))
el.set('ry', str(self.ry))
elif self.element_type == 'line':
el.set('x1', str(self.x1))
el.set('y1', str(self.y1))
el.set('x2', str(self.x2))
el.set('y2', str(self.y2))
elif self.element_type == 'text':
el.set('x', str(self.x))
el.set('y', str(self.y))
el.set('font-size', str(self.font_size))
el.set('font-family', self.font_family)
if self.text_anchor != 'start':
el.set('text-anchor', self.text_anchor)
el.text = self.text
elif self.element_type == 'path':
el.set('d', self.d)
elif self.element_type == 'polygon':
el.set('points', self.points)
elif self.element_type == 'polyline':
el.set('points', self.points)
# Common style attributes
if self.fill and self.fill != 'none':
el.set('fill', self.fill)
else:
el.set('fill', 'none')
if self.stroke:
el.set('stroke', self.stroke)
if self.stroke_width != 1:
el.set('stroke-width', str(self.stroke_width))
if self.opacity != 1.0:
el.set('opacity', str(self.opacity))
return el
def to_svg_string(self) -> str:
"""Convert to SVG string."""
el = self.to_xml_element()
return ET.tostring(el, encoding='unicode')
def generate_element_id() -> str:
"""Generate a unique element ID."""
return f"el-{str(uuid.uuid4())[:8]}"
def _parse_float(value: str, default: float = 0) -> float:
"""Safely parse a float value, handling units like %, px, em, etc."""
if not value:
return default
# Remove common units
value = value.strip()
for suffix in ('%', 'px', 'em', 'rem', 'pt', 'pc', 'in', 'cm', 'mm'):
if value.endswith(suffix):
value = value[:-len(suffix)]
break
try:
return float(value)
except ValueError:
return default
def parse_svg_elements(source: str) -> List[SVGElement]:
"""Parse SVG and return list of elements.
Args:
source: SVG XML string
Returns:
List of SVGElement objects
"""
if not source.strip():
return []
try:
# Handle namespace
source_clean = re.sub(r'xmlns="[^"]*"', '', source)
root = ET.fromstring(source_clean)
except ET.ParseError:
return []
elements = []
# Element types we care about
shape_types = {'rect', 'circle', 'ellipse', 'line', 'text', 'path', 'polygon', 'polyline'}
def parse_element(el: ET.Element) -> Optional[SVGElement]:
"""Parse a single XML element into SVGElement."""
tag = el.tag.split('}')[-1] # Remove namespace if present
if tag not in shape_types:
return None
elem_id = el.get('id', generate_element_id())
# Parse common attributes
fill = el.get('fill', 'none')
stroke = el.get('stroke', '#000000')
stroke_width = _parse_float(el.get('stroke-width', '1'), 1)
opacity = _parse_float(el.get('opacity', '1'), 1)
# Get raw XML for replacement
raw_xml = ET.tostring(el, encoding='unicode')
if tag == 'rect':
return SVGElement(
element_type='rect',
id=elem_id,
x=_parse_float(el.get('x', '0')),
y=_parse_float(el.get('y', '0')),
width=_parse_float(el.get('width', '100'), 100),
height=_parse_float(el.get('height', '100'), 100),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'circle':
return SVGElement(
element_type='circle',
id=elem_id,
cx=_parse_float(el.get('cx', '0')),
cy=_parse_float(el.get('cy', '0')),
r=_parse_float(el.get('r', '50'), 50),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'ellipse':
return SVGElement(
element_type='ellipse',
id=elem_id,
cx=_parse_float(el.get('cx', '0')),
cy=_parse_float(el.get('cy', '0')),
rx=_parse_float(el.get('rx', '50'), 50),
ry=_parse_float(el.get('ry', '30'), 30),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'line':
return SVGElement(
element_type='line',
id=elem_id,
x1=_parse_float(el.get('x1', '0')),
y1=_parse_float(el.get('y1', '0')),
x2=_parse_float(el.get('x2', '100'), 100),
y2=_parse_float(el.get('y2', '100'), 100),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'text':
# Get text content (may include tspan children)
text_content = el.text or ''
for child in el:
if child.text:
text_content += child.text
if child.tail:
text_content += child.tail
return SVGElement(
element_type='text',
id=elem_id,
x=_parse_float(el.get('x', '0')),
y=_parse_float(el.get('y', '0')),
text=text_content.strip(),
font_size=int(_parse_float(el.get('font-size', '16'), 16)),
font_family=el.get('font-family', 'sans-serif'),
text_anchor=el.get('text-anchor', 'start'),
fill=fill if fill != 'none' else '#000000',
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'path':
return SVGElement(
element_type='path',
id=elem_id,
d=el.get('d', ''),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'polygon':
return SVGElement(
element_type='polygon',
id=elem_id,
points=el.get('points', ''),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
elif tag == 'polyline':
return SVGElement(
element_type='polyline',
id=elem_id,
points=el.get('points', ''),
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
opacity=opacity,
raw_xml=raw_xml,
)
return None
def traverse(el: ET.Element, parent_group: str = ""):
"""Recursively traverse and collect elements."""
tag = el.tag.split('}')[-1] # Remove namespace
# Track if this is a group
current_group = parent_group
if tag == 'g':
group_id = el.get('id', '')
if group_id:
current_group = group_id
parsed = parse_element(el)
if parsed:
parsed.parent_group = parent_group # Set the parent group
elements.append(parsed)
for child in el:
traverse(child, current_group)
traverse(root)
return elements
def parse_svg_groups(source: str) -> List[str]:
"""Parse SVG and return list of group IDs.
Args:
source: SVG XML string
Returns:
List of group ID strings
"""
if not source.strip():
return []
try:
source_clean = re.sub(r'xmlns="[^"]*"', '', source)
root = ET.fromstring(source_clean)
except ET.ParseError:
return []
groups = []
def traverse(el: ET.Element):
tag = el.tag.split('}')[-1]
if tag == 'g':
group_id = el.get('id', '')
if group_id:
groups.append(group_id)
for child in el:
traverse(child)
traverse(root)
return groups
def get_svg_document(source: str) -> Tuple[ET.Element, Dict[str, str]]:
"""Parse and return the SVG root element and namespaces."""
if not source.strip():
root = ET.Element('svg')
root.set('xmlns', 'http://www.w3.org/2000/svg')
root.set('viewBox', '0 0 800 600')
root.set('width', '800')
root.set('height', '600')
return root, {}
# Extract namespaces before parsing
namespaces = {}
for match in re.finditer(r'xmlns(?::(\w+))?="([^"]*)"', source):
prefix = match.group(1) or ''
uri = match.group(2)
namespaces[prefix] = uri
try:
# Remove default namespace for easier parsing
source_clean = re.sub(r'xmlns="[^"]*"', '', source)
root = ET.fromstring(source_clean)
return root, namespaces
except ET.ParseError:
root = ET.Element('svg')
root.set('xmlns', 'http://www.w3.org/2000/svg')
root.set('viewBox', '0 0 800 600')
return root, {}
def add_svg_element(source: str, element: SVGElement) -> str:
"""Add a new element to the SVG document.
Args:
source: Current SVG XML
element: Element to add
Returns:
Updated SVG string
"""
if not source.strip():
# Create new SVG document
source = '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
</svg>'''
# Find the closing </svg> tag and insert before it
new_element_str = ' ' + element.to_svg_string() + '\n'
# Insert before </svg>
if '</svg>' in source:
source = source.replace('</svg>', new_element_str + '</svg>')
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'<rect\s+(?=[^>]*\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'<circle\s+(?=[^>]*\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'<ellipse\s+(?=[^>]*\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'<line\s+(?=[^>]*\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'<text\s+(?=[^>]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?</text>'
elif tag == 'path':
# Match path by d attribute (escape special chars)
d_escaped = re.escape(el.d[:50]) if el.d else ''
pattern = rf'<path\s+(?=[^>]*\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]*<rect\s+(?=[^>]*\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]*<circle\s+(?=[^>]*\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]*<ellipse\s+(?=[^>]*\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]*<line\s+(?=[^>]*\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]*<text\s+(?=[^>]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?</text>[ \t]*\n?'
elif tag == 'path':
d_escaped = re.escape(el.d[:50]) if el.d else ''
pattern = rf'[ \t]*<path\s+(?=[^>]*\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]*)<rect\s+(?=[^>]*\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]*)<circle\s+(?=[^>]*\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]*)<ellipse\s+(?=[^>]*\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]*)<line\s+(?=[^>]*\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]*)<text\s+(?=[^>]*\bx="{x_pat}")(?=[^>]*\by="{y_pat}")[^>]*>.*?</text>[ \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 </svg> tag and insert before it
svg_close = source.rfind('</svg>')
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 <svg> tag end and insert after it
svg_open_match = re.search(r'<svg[^>]*>', 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}<g id="{group_id}">\n{indent} {element_xml}\n{indent}</g>'
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>(?=[^<]*(?:<[^g]|$))' # Find </g> that belongs to our group
# More reliable: find the group by id, then find its closing tag
group_pattern = rf'<g[^>]*\bid="{re.escape(group_id)}"[^>]*>'
group_match = re.search(group_pattern, source)
if not group_match:
return source
# Find the matching closing </g> (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('<g', pos)
close_g = source.find('</g>', 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 </g>
insert_pos = close_g
# Get indent from group
indent_match = re.search(r'\n([ \t]+)</g>', 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>)
svg_close = source.rfind('</svg>')
if svg_close == -1:
return source
insert_text = f"\n {element_xml}\n"
return source[:svg_close] + insert_text + source[svg_close:]

View File

@ -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 <elem_type followed by attributes but no id="
pattern = rf'<{elem_type}(?![^>]*\bid=)([^>]*?)(/?>)'
def add_id(match):
attrs = match.group(1)
closing = match.group(2)
new_id = f'el-{uuid.uuid4().hex[:8]}'
return f'<{elem_type} id="{new_id}"{attrs}{closing}'
result = re.sub(pattern, add_id, result)
return result
def _parse_dimensions(self, svg_source: str):
"""Extract width, height, and viewBox from SVG source."""
import re
# Try width/height attributes
width_match = re.search(r'<svg[^>]*\bwidth=["\']([0-9.]+)', svg_source)
height_match = re.search(r'<svg[^>]*\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 = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {self.svg_width} {self.svg_height}" '
f'width="{self.svg_width}" height="{self.svg_height}">',
' <rect width="100%" height="100%" fill="#f9fafb"/>',
]
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('</svg>')
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'<rect {attrs} x="{elem.x:.1f}" y="{elem.y:.1f}" width="{elem.width:.1f}" height="{elem.height:.1f}"/>'
elif elem.element_type == 'circle':
return f'<circle {attrs} cx="{elem.cx:.1f}" cy="{elem.cy:.1f}" r="{elem.r:.1f}"/>'
elif elem.element_type == 'ellipse':
return f'<ellipse {attrs} cx="{elem.cx:.1f}" cy="{elem.cy:.1f}" rx="{elem.rx:.1f}" ry="{elem.ry:.1f}"/>'
elif elem.element_type == 'line':
return f'<line {attrs} x1="{elem.x1:.1f}" y1="{elem.y1:.1f}" x2="{elem.x2:.1f}" y2="{elem.y2:.1f}"/>'
elif elem.element_type == 'text':
return f'<text {attrs} x="{elem.x:.1f}" y="{elem.y:.1f}" font-family="{elem.font_family}" font-size="{elem.font_size}">{elem.text}</text>'
elif elem.element_type == 'path':
return f'<path {attrs} d="{elem.d}"/>'
elif elem.element_type == 'polygon':
return f'<polygon {attrs} points="{elem.points}"/>'
elif elem.element_type == 'polyline':
return f'<polyline {attrs} points="{elem.points}"/>'
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