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:
parent
1ac0b181e1
commit
a8cc90c128
|
|
@ -15,7 +15,7 @@ from enum import Enum, auto
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 (
|
from PyQt6.QtGui import (
|
||||||
QFont, QColor, QAction, QKeySequence, QSyntaxHighlighter,
|
QFont, QColor, QAction, QKeySequence, QSyntaxHighlighter,
|
||||||
QTextCharFormat, QPainter, QPen, QBrush
|
QTextCharFormat, QPainter, QPen, QBrush
|
||||||
|
|
@ -26,11 +26,12 @@ from PyQt6.QtWidgets import (
|
||||||
QFileDialog, QMessageBox, QStatusBar, QToolBar, QComboBox,
|
QFileDialog, QMessageBox, QStatusBar, QToolBar, QComboBox,
|
||||||
QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsEllipseItem,
|
QGraphicsView, QGraphicsScene, QGraphicsRectItem, QGraphicsEllipseItem,
|
||||||
QGraphicsTextItem, QGraphicsLineItem, QGraphicsItem,
|
QGraphicsTextItem, QGraphicsLineItem, QGraphicsItem,
|
||||||
QListWidget, QListWidgetItem, QGroupBox
|
QListWidget, QListWidgetItem, QGroupBox, QSpinBox, QCheckBox, QDialog
|
||||||
)
|
)
|
||||||
from PyQt6.QtSvgWidgets import QSvgWidget
|
from PyQt6.QtSvgWidgets import QSvgWidget
|
||||||
|
|
||||||
from .renderers import get_renderer, list_renderers
|
from .renderers import get_renderer, list_renderers
|
||||||
|
from .renderers.code import get_languages, get_themes, is_available as code_renderer_available
|
||||||
from .dialogs import (
|
from .dialogs import (
|
||||||
DIAGRAM_TYPES, AddElementDialog, AddRelationshipDialog,
|
DIAGRAM_TYPES, AddElementDialog, AddRelationshipDialog,
|
||||||
EditElementDialog, DiagramElement,
|
EditElementDialog, DiagramElement,
|
||||||
|
|
@ -39,6 +40,10 @@ from .dialogs import (
|
||||||
EditMermaidElementDialog,
|
EditMermaidElementDialog,
|
||||||
# OpenSCAD dialogs
|
# OpenSCAD dialogs
|
||||||
AddOpenSCADElementDialog, EditOpenSCADElementDialog,
|
AddOpenSCADElementDialog, EditOpenSCADElementDialog,
|
||||||
|
# Excalidraw dialogs
|
||||||
|
AddExcalidrawElementDialog, EditExcalidrawElementDialog,
|
||||||
|
# SVG dialogs
|
||||||
|
AddSVGElementDialog, EditSVGElementDialog,
|
||||||
)
|
)
|
||||||
from .parser import (
|
from .parser import (
|
||||||
detect_diagram_type, parse_elements, parse_relationships,
|
detect_diagram_type, parse_elements, parse_relationships,
|
||||||
|
|
@ -52,6 +57,17 @@ from .openscad_parser import (
|
||||||
parse_openscad_elements, insert_openscad_element, delete_openscad_element,
|
parse_openscad_elements, insert_openscad_element, delete_openscad_element,
|
||||||
replace_openscad_element, get_openscad_module_names
|
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
|
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
|
finished = pyqtSignal(bool, str) # success, message/path
|
||||||
|
|
||||||
def __init__(self, renderer, source: str, output_path: 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__()
|
super().__init__()
|
||||||
self.renderer = renderer
|
self.renderer = renderer
|
||||||
self.source = source
|
self.source = source
|
||||||
self.output_path = output_path
|
self.output_path = output_path
|
||||||
self.camera_angles = camera_angles
|
self.camera_angles = camera_angles
|
||||||
|
self.code_options = code_options
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
# Pass camera_angles if renderer supports it (OpenSCAD)
|
|
||||||
if self.camera_angles and hasattr(self.renderer, 'render'):
|
|
||||||
import inspect
|
import inspect
|
||||||
sig = inspect.signature(self.renderer.render)
|
sig = inspect.signature(self.renderer.render)
|
||||||
if 'camera_angles' in sig.parameters:
|
|
||||||
|
# Pass camera_angles if renderer supports it (OpenSCAD)
|
||||||
|
if self.camera_angles and 'camera_angles' in sig.parameters:
|
||||||
success, message = self.renderer.render(
|
success, message = self.renderer.render(
|
||||||
self.source, self.output_path, camera_angles=self.camera_angles
|
self.source, self.output_path, camera_angles=self.camera_angles
|
||||||
)
|
)
|
||||||
else:
|
# Pass code_options if renderer supports it (Code)
|
||||||
success, message = self.renderer.render(self.source, self.output_path)
|
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:
|
else:
|
||||||
success, message = self.renderer.render(self.source, self.output_path)
|
success, message = self.renderer.render(self.source, self.output_path)
|
||||||
self.finished.emit(success, message)
|
self.finished.emit(success, message)
|
||||||
|
|
@ -194,9 +215,154 @@ class DictateThread(QThread):
|
||||||
self.finished.emit(f"[Error: {e}]")
|
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):
|
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
|
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):
|
def __init__(self, instruction: str, current_code: str, artifact_type: str):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -204,11 +370,112 @@ class AIThread(QThread):
|
||||||
self.current_code = current_code
|
self.current_code = current_code
|
||||||
self.artifact_type = artifact_type
|
self.artifact_type = artifact_type
|
||||||
|
|
||||||
def run(self):
|
def _get_renderer(self):
|
||||||
try:
|
"""Get the renderer for validation."""
|
||||||
# Try discussion-diagram-editor SmartTool first
|
return get_renderer(self.artifact_type)
|
||||||
tool_path = Path.home() / ".local" / "bin" / "discussion-diagram-editor"
|
|
||||||
|
|
||||||
|
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():
|
if tool_path.exists():
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[str(tool_path), "--instruction", self.instruction],
|
[str(tool_path), "--instruction", self.instruction],
|
||||||
|
|
@ -218,41 +485,70 @@ class AIThread(QThread):
|
||||||
timeout=60
|
timeout=60
|
||||||
)
|
)
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
self.finished.emit(True, result.stdout)
|
return True, result.stdout
|
||||||
return
|
|
||||||
|
|
||||||
# Fallback: try claude directly
|
# Use claude directly for all formats
|
||||||
if subprocess.run(["which", "claude"], capture_output=True).returncode == 0:
|
if subprocess.run(["which", "claude"], capture_output=True).returncode == 0:
|
||||||
prompt = f"""You are a {self.artifact_type} diagram expert.
|
|
||||||
|
|
||||||
Current diagram code:
|
|
||||||
```
|
|
||||||
{self.current_code}
|
|
||||||
```
|
|
||||||
|
|
||||||
User request: {self.instruction}
|
|
||||||
|
|
||||||
Respond with ONLY the updated diagram code, no explanations."""
|
|
||||||
|
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["claude", "-p", "--model", "haiku"],
|
["claude", "-p", "--model", "sonnet"],
|
||||||
input=prompt,
|
input=prompt,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=60
|
timeout=120
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.returncode == 0:
|
if result.returncode == 0:
|
||||||
# Extract code from response
|
return True, result.stdout
|
||||||
code = result.stdout.strip()
|
|
||||||
if "```" in code:
|
return False, "No AI tools available (claude CLI not found)"
|
||||||
match = re.search(r'```(?:\w+)?\n(.*?)```', code, re.DOTALL)
|
|
||||||
if match:
|
def run(self):
|
||||||
code = match.group(1).strip()
|
try:
|
||||||
self.finished.emit(True, code)
|
retry_count = 0
|
||||||
|
last_error = None
|
||||||
|
last_output = None
|
||||||
|
|
||||||
|
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 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
|
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:
|
except subprocess.TimeoutExpired:
|
||||||
self.finished.emit(False, "AI request timed out")
|
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)
|
rotation_changed = pyqtSignal(int, int) # Emitted for 3D rotation (delta_x, delta_y)
|
||||||
zoom_changed = pyqtSignal(int) # Emitted for zoom change (delta)
|
zoom_changed = pyqtSignal(int) # Emitted for zoom change (delta)
|
||||||
element_clicked = pyqtSignal(str) # Emitted when an element is clicked (element name)
|
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):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
@ -554,6 +851,7 @@ class DiagramCanvas(QGraphicsView):
|
||||||
# Normal mode: scale the view
|
# Normal mode: scale the view
|
||||||
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
|
||||||
self.scale(factor, factor)
|
self.scale(factor, factor)
|
||||||
|
self.view_transform_changed.emit()
|
||||||
event.accept()
|
event.accept()
|
||||||
elif event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
|
elif event.modifiers() == Qt.KeyboardModifier.ShiftModifier:
|
||||||
# Shift+wheel for horizontal scrolling
|
# Shift+wheel for horizontal scrolling
|
||||||
|
|
@ -722,6 +1020,8 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
# Initial render
|
# Initial render
|
||||||
self._schedule_render()
|
self._schedule_render()
|
||||||
|
|
||||||
|
# SVG selection will be enabled after first render in _update_svg_overlay
|
||||||
|
|
||||||
# Show getting started for new diagrams
|
# Show getting started for new diagrams
|
||||||
if not initial_content or initial_content.strip() == self.renderer.get_template().strip():
|
if not initial_content or initial_content.strip() == self.renderer.get_template().strip():
|
||||||
# Use a timer to show after window is displayed
|
# Use a timer to show after window is displayed
|
||||||
|
|
@ -976,6 +1276,10 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
right_layout.addWidget(self.canvas)
|
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)
|
# 3D rotation controls (for OpenSCAD)
|
||||||
self.rotation_panel = QWidget()
|
self.rotation_panel = QWidget()
|
||||||
rotation_layout = QHBoxLayout(self.rotation_panel)
|
rotation_layout = QHBoxLayout(self.rotation_panel)
|
||||||
|
|
@ -1028,6 +1332,90 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
self.rotation_panel.setVisible(False) # Hidden by default
|
self.rotation_panel.setVisible(False) # Hidden by default
|
||||||
right_layout.addWidget(self.rotation_panel)
|
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)
|
self.splitter.addWidget(right_panel)
|
||||||
|
|
||||||
# Set initial split ratio (50/50)
|
# Set initial split ratio (50/50)
|
||||||
|
|
@ -1243,6 +1631,19 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
self.canvas.zoom_changed.connect(self._on_canvas_zoom)
|
self.canvas.zoom_changed.connect(self._on_canvas_zoom)
|
||||||
self.canvas.element_clicked.connect(self._on_element_clicked)
|
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):
|
def _set_state(self, state: EditorState):
|
||||||
"""Update editor state machine."""
|
"""Update editor state machine."""
|
||||||
self.state = state
|
self.state = state
|
||||||
|
|
@ -1297,6 +1698,14 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
self.rotation_panel.setVisible(is_openscad)
|
self.rotation_panel.setVisible(is_openscad)
|
||||||
self.canvas.set_rotation_mode(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
|
# Rebuild templates menu for this format
|
||||||
self._rebuild_templates_menu()
|
self._rebuild_templates_menu()
|
||||||
|
|
||||||
|
|
@ -1363,8 +1772,12 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
# Create temp file for output
|
# Create temp file for output
|
||||||
# OpenSCAD 3D models must render to PNG (SVG is 2D only)
|
# 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
|
# 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"
|
suffix = ".png"
|
||||||
|
elif self.artifact_type in ("svg", "excalidraw"):
|
||||||
|
suffix = ".svg"
|
||||||
else:
|
else:
|
||||||
suffix = ".svg"
|
suffix = ".svg"
|
||||||
temp_output = Path(tempfile.mktemp(suffix=suffix))
|
temp_output = Path(tempfile.mktemp(suffix=suffix))
|
||||||
|
|
@ -1380,7 +1793,19 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
self.zoom_slider.value()
|
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.finished.connect(self._on_render_finished)
|
||||||
self.render_thread.start()
|
self.render_thread.start()
|
||||||
|
|
||||||
|
|
@ -1389,6 +1814,10 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
if success:
|
if success:
|
||||||
self.canvas.show_preview(message)
|
self.canvas.show_preview(message)
|
||||||
self._set_state(EditorState.PREVIEW)
|
self._set_state(EditorState.PREVIEW)
|
||||||
|
|
||||||
|
# Update SVG overlay if this is an SVG artifact
|
||||||
|
if self.artifact_type == "svg":
|
||||||
|
self._update_svg_overlay()
|
||||||
else:
|
else:
|
||||||
self.canvas.show_error(message)
|
self.canvas.show_error(message)
|
||||||
self._set_state(EditorState.ERROR)
|
self._set_state(EditorState.ERROR)
|
||||||
|
|
@ -1403,11 +1832,17 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
current_code = self.code_editor.toPlainText()
|
current_code = self.code_editor.toPlainText()
|
||||||
self.ai_thread = AIThread(instruction, current_code, self.artifact_type)
|
self.ai_thread = AIThread(instruction, current_code, self.artifact_type)
|
||||||
self.ai_thread.finished.connect(self._on_ai_finished)
|
self.ai_thread.finished.connect(self._on_ai_finished)
|
||||||
|
self.ai_thread.status_update.connect(self._on_ai_status)
|
||||||
self.ai_thread.start()
|
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):
|
def _on_ai_finished(self, success: bool, result: str):
|
||||||
"""Handle AI generation completion."""
|
"""Handle AI generation completion."""
|
||||||
self.ai_panel.set_ai_processing(False)
|
self.ai_panel.set_ai_processing(False)
|
||||||
|
self._set_state(EditorState.IDLE)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
self.code_editor.setPlainText(result)
|
self.code_editor.setPlainText(result)
|
||||||
|
|
@ -1483,6 +1918,22 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
item.setData(Qt.ItemDataRole.UserRole, element)
|
item.setData(Qt.ItemDataRole.UserRole, element)
|
||||||
self.elements_list.addItem(item)
|
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):
|
def _on_diagram_type_changed(self, diagram_type: str):
|
||||||
"""Handle diagram type selection change."""
|
"""Handle diagram type selection change."""
|
||||||
self.detected_diagram_type = diagram_type
|
self.detected_diagram_type = diagram_type
|
||||||
|
|
@ -1490,22 +1941,40 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
def _update_elements_panel_for_format(self):
|
def _update_elements_panel_for_format(self):
|
||||||
"""Update elements panel UI for the current artifact format."""
|
"""Update elements panel UI for the current artifact format."""
|
||||||
if self.artifact_type == "plantuml":
|
if self.artifact_type == "plantuml":
|
||||||
|
self.elements_group.setVisible(True)
|
||||||
self.elements_group.setTitle("Diagram Elements")
|
self.elements_group.setTitle("Diagram Elements")
|
||||||
self.add_element_btn.setText("+ Element")
|
self.add_element_btn.setText("+ Element")
|
||||||
self.add_rel_btn.setText("+ Relationship")
|
self.add_rel_btn.setText("+ Relationship")
|
||||||
self.add_rel_btn.setVisible(True)
|
self.add_rel_btn.setVisible(True)
|
||||||
self.diagram_type_combo.setVisible(True)
|
self.diagram_type_combo.setVisible(True)
|
||||||
elif self.artifact_type == "mermaid":
|
elif self.artifact_type == "mermaid":
|
||||||
|
self.elements_group.setVisible(True)
|
||||||
self.elements_group.setTitle("Diagram Elements")
|
self.elements_group.setTitle("Diagram Elements")
|
||||||
self.add_element_btn.setText("+ Node")
|
self.add_element_btn.setText("+ Node")
|
||||||
self.add_rel_btn.setText("+ Connection")
|
self.add_rel_btn.setText("+ Connection")
|
||||||
self.add_rel_btn.setVisible(True)
|
self.add_rel_btn.setVisible(True)
|
||||||
self.diagram_type_combo.setVisible(False)
|
self.diagram_type_combo.setVisible(False)
|
||||||
elif self.artifact_type == "openscad":
|
elif self.artifact_type == "openscad":
|
||||||
|
self.elements_group.setVisible(True)
|
||||||
self.elements_group.setTitle("OpenSCAD Elements")
|
self.elements_group.setTitle("OpenSCAD Elements")
|
||||||
self.add_element_btn.setText("+ Element")
|
self.add_element_btn.setText("+ Element")
|
||||||
self.add_rel_btn.setVisible(False) # OpenSCAD doesn't have relationships
|
self.add_rel_btn.setVisible(False) # OpenSCAD doesn't have relationships
|
||||||
self.diagram_type_combo.setVisible(False)
|
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:
|
else:
|
||||||
# Default/unknown format
|
# Default/unknown format
|
||||||
self.elements_group.setVisible(False)
|
self.elements_group.setVisible(False)
|
||||||
|
|
@ -1566,6 +2035,22 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
self.code_editor.setPlainText(new_code)
|
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()
|
self._refresh_elements_list()
|
||||||
|
|
||||||
def _on_add_relationship(self):
|
def _on_add_relationship(self):
|
||||||
|
|
@ -1654,6 +2139,23 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
code = replace_mermaid_element(code, element, new_code)
|
code = replace_mermaid_element(code, element, new_code)
|
||||||
self.code_editor.setPlainText(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):
|
def _on_delete_element(self):
|
||||||
"""Delete selected element from diagram."""
|
"""Delete selected element from diagram."""
|
||||||
current_item = self.elements_list.currentItem()
|
current_item = self.elements_list.currentItem()
|
||||||
|
|
@ -1689,6 +2191,11 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
new_code = delete_mermaid_element(code, element.id)
|
new_code = delete_mermaid_element(code, element.id)
|
||||||
elif self.artifact_type == "openscad":
|
elif self.artifact_type == "openscad":
|
||||||
new_code = delete_openscad_element(code, element.name)
|
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:
|
else:
|
||||||
new_code = code
|
new_code = code
|
||||||
|
|
||||||
|
|
@ -1701,6 +2208,11 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
if self.artifact_type == "openscad":
|
if self.artifact_type == "openscad":
|
||||||
self._schedule_render(200) # Short debounce for smooth interaction
|
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):
|
def _reset_rotation(self):
|
||||||
"""Reset rotation sliders to default OpenSCAD view."""
|
"""Reset rotation sliders to default OpenSCAD view."""
|
||||||
# Block signals to prevent multiple renders
|
# Block signals to prevent multiple renders
|
||||||
|
|
@ -2043,6 +2555,191 @@ class ArtifactEditorWindow(QMainWindow):
|
||||||
self.output_path = Path(path)
|
self.output_path = Path(path)
|
||||||
self._save()
|
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):
|
def closeEvent(self, event):
|
||||||
"""Handle window close."""
|
"""Handle window close."""
|
||||||
if self.is_modified:
|
if self.is_modified:
|
||||||
|
|
|
||||||
|
|
@ -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:]
|
||||||
|
|
@ -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
|
||||||
Loading…
Reference in New Issue