From a8cc90c128f22c32ffde4dbf75dc05305dcb2dd2 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 23 Dec 2025 05:00:32 -0400 Subject: [PATCH] feat: Add scene-based SVG editing with exact element positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/artifact_editor/gui.py | 787 ++++++++++++++++++++++-- src/artifact_editor/svg_parser.py | 953 ++++++++++++++++++++++++++++++ src/artifact_editor/svg_scene.py | 863 +++++++++++++++++++++++++++ 3 files changed, 2558 insertions(+), 45 deletions(-) create mode 100644 src/artifact_editor/svg_parser.py create mode 100644 src/artifact_editor/svg_scene.py 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: +- Shapes: +- Circle: +- Ellipse: +- Line: +- Text: content +- Path: +- Groups: ... + +Example: +```svg + + + + + Hello 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 . 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*()', 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"\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: diff --git a/src/artifact_editor/svg_parser.py b/src/artifact_editor/svg_parser.py new file mode 100644 index 0000000..976a8dc --- /dev/null +++ b/src/artifact_editor/svg_parser.py @@ -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 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 = ''' + +''' + + # Find the closing tag and insert before it + new_element_str = ' ' + element.to_svg_string() + '\n' + + # Insert before + 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)}["\']?[^>]*(?:/>|>.*?)' + 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)}["\']?[^>]*(?:/>|>.*?)[ \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)}["\']?[^>]*(?:/>|>.*?)' + 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[^>]*(?:/>|>.*?)' + 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[^>]*(?:/>|>.*?)' + + # 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