Complete implementation with Docker and SmartTools

- Full PyQt6 GUI for 6 artifact formats
- artifact-ai SmartTool for AI generation
- artifact-export SmartTool for format conversion
- Interactive SVG editing with drag/resize
- Voice dictation with countdown timer
- Docker support for clean-system testing

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2025-12-23 23:40:40 -04:00
parent 6aa226b136
commit d20d7e5415
16 changed files with 5210 additions and 282 deletions

35
.dockerignore Normal file
View File

@ -0,0 +1,35 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
*.egg-info
.eggs
*.egg
dist
build
.venv
venv
ENV
# IDE
.idea
.vscode
*.swp
# Docker
Dockerfile*
docker-compose*
.docker
# Misc
*.md
!README.md
.coverage
.pytest_cache
htmlcov

175
CLAUDE.md Normal file
View File

@ -0,0 +1,175 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Install in development mode
pip install -e .
# Install with all dependencies
pip install -e ".[dev]"
# Run the editor
artifact-editor
# Run from source (development)
PYTHONPATH=src python3 -m artifact_editor.cli
# Run with specific format
artifact-editor --type svg --output /tmp/test.svg
```
## Architecture
Artifact Editor is a standalone PyQt6-based GUI for creating visual artifacts (diagrams, 3D models, code snippets) from code-based formats. It's designed to integrate with other tools via stdin/stdout and exit codes.
### Project Ecosystem
This is part of a three-project stack:
1. **SmartTools** - AI provider abstraction and tool execution (dependency)
2. **Orchestrated Discussions** - Conversation orchestration (launches this editor)
3. **Artifact Editor** (this) - Visual artifact creation
### Integration Protocol
When launched by a parent application (e.g., Orchestrated Discussions):
```
Parent App Artifact Editor
│ │
├─ subprocess.run([ │
│ "artifact-editor", │
│ "--output", "path.puml" │
│ ]) │
│ ├─ User edits/creates artifact
│ ├─ User may switch format (e.g., .puml → .svg)
│ ├─ User clicks Save & Exit
│ │
│ ◄──────────────────────────────── ├─ stdout: "ARTIFACT_SAVED:/actual/path.svg"
│ ├─ exit code: 0 (success)
│ │
├─ Parse stdout for ARTIFACT_SAVED │
├─ Use actual path (not suggested) │
```
**Exit Codes:**
- `0` - Success, file saved. stdout contains `ARTIFACT_SAVED:/path`
- `1` - User cancelled (closed without saving)
- `2` - Error (stderr contains message)
### Key Design Decisions
1. **Code-based formats** - All artifacts are stored as text (PlantUML, Mermaid, SVG, etc.) so AI can read and modify them
2. **Format switching** - User can change format; `output_path` extension updates automatically
3. **Non-destructive** - Undo/redo works for both code and visual edits
4. **Subprocess integration** - Clean stdin/stdout contract, no shared state
### Source Structure
```
src/artifact_editor/
├── cli.py # Entry point, argument parsing
├── gui.py # PyQt6 main window (~2800 lines)
├── dialogs.py # Element editing dialogs per format
├── templates.py # Pre-built templates (100+)
├── parser.py # PlantUML parser
├── mermaid_parser.py # Mermaid parser
├── openscad_parser.py # OpenSCAD parser
├── excalidraw_parser.py # Excalidraw JSON parser
├── svg_parser.py # SVG XML parsing
├── svg_interactive.py # Qt-based SVG interaction
├── svg_scene.py # QGraphicsScene SVG rendering
└── renderers/
├── __init__.py # Renderer registry
├── plantuml.py # Calls `plantuml` CLI
├── mermaid.py # Calls `mmdc` CLI
├── openscad.py # Calls `openscad` CLI
├── code.py # Pygments syntax highlighting
├── svg.py # Direct SVG rendering
└── excalidraw.py # Excalidraw JSON → SVG
```
### Key Classes
- **`ArtifactEditorWindow`** - Main PyQt6 window with split view
- **`RenderThread`** - Background rendering to avoid UI freezes
- **`DictateThread`** - Voice input with countdown timer
- **`AIThread`** - AI generation in background
- **`SVGSceneManager`** - Interactive SVG editing with QGraphicsScene
### AI Integration
Uses the `artifact-ai` SmartTool for AI-powered generation:
```python
subprocess.run(
["artifact-ai", "--format", format_type, "--instruction", instruction],
input=current_code,
capture_output=True
)
```
No direct AI provider imports - follows Unix philosophy.
### SmartTools
Artifact Editor includes SmartTools for CLI/scripting use:
```
smarttools/
├── artifact-ai/ # AI-powered artifact generation/modification
│ └── config.yaml # Supports all formats: plantuml, mermaid, openscad, svg, excalidraw, code
└── artifact-export/ # Render source to binary formats (PNG, SVG, PDF)
└── config.yaml
```
**artifact-ai** - Generate or modify artifacts using AI:
```bash
# Create new artifact
echo "" | artifact-ai --format plantuml --instruction "Create a sequence diagram for login"
# Modify existing artifact
cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer"
```
**artifact-export** - Render artifacts to binary formats:
```bash
cat diagram.puml | artifact-export --format plantuml --to output.svg
cat model.scad | artifact-export --format openscad --to output.png
```
These tools are installed to `~/.smarttools/` and `~/.local/bin/` by `./install.sh`.
### Supported Formats
| Format | Extension | Renderer | Visual Editing |
|--------|-----------|----------|----------------|
| PlantUML | `.puml` | `plantuml` CLI | Element dialogs |
| Mermaid | `.mmd` | `mmdc` CLI | Node/edge dialogs |
| OpenSCAD | `.scad` | `openscad` CLI | 3D rotation, primitives |
| Code | `.py`, etc. | Pygments | Syntax highlighting |
| SVG | `.svg` | Direct | Interactive drag/resize |
| Excalidraw | `.json` | Built-in | Hand-drawn style |
### Toolbar Actions
- **Format selector** - Switch between artifact types (updates file extension)
- **Save** - Save to current path
- **Save & Exit** - Save and close (for integration workflows)
- **Render** button by preview - Force re-render (Ctrl+R)
### AI Panel
Bottom of window:
- **Text input** - Natural language instructions
- **Dictate** - 10-second voice recording with countdown (`🎤 9s`, `🎤 8s`...)
- **AI Generate** - Sends to Claude, updates code editor
### External Dependencies
Required for rendering:
- `plantuml` - For PlantUML diagrams
- `mmdc` (mermaid-cli) - For Mermaid diagrams
- `openscad` - For 3D CAD models
- `pygments` - For code syntax highlighting

118
Dockerfile Normal file
View File

@ -0,0 +1,118 @@
# Artifact Editor - AI-enhanced visual artifact creation
#
# Multi-stage build:
# Stage 1: Build SmartTools base
# Stage 2: Build Artifact Editor with SmartTools
#
# Build: docker build -t artifact-editor .
# Run: docker run -it --rm artifact-editor artifact-ai --help
# GUI: See usage examples at bottom
# ==============================================================================
# Stage 1: SmartTools Base
# ==============================================================================
FROM python:3.12-slim AS smarttools
WORKDIR /smarttools
ARG SMARTTOOLS_REPO=https://gitea.brrd.tech/rob/SmartTools.git
RUN apt-get update && apt-get install -y --no-install-recommends git && \
git clone ${SMARTTOOLS_REPO} . || \
echo "Clone failed - will need COPY in next stage"
RUN pip install --no-cache-dir -e . || true
# ==============================================================================
# Stage 2: Artifact Editor
# ==============================================================================
FROM python:3.12-slim
LABEL maintainer="rob"
LABEL description="Artifact Editor - AI-enhanced diagram and artifact creation"
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
plantuml \
# PyQt6 dependencies (for GUI - optional for CLI use)
libgl1-mesa-glx \
libegl1-mesa \
libxkbcommon0 \
libdbus-1-3 \
libxcb-cursor0 \
libxcb-icccm4 \
libxcb-keysyms1 \
libxcb-shape0 \
libxcb-xinerama0 \
libxcb-randr0 \
libxcb-render-util0 \
# For inkscape/rsvg (SVG to PNG conversion)
librsvg2-bin \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js and mermaid-cli (optional, for Mermaid diagrams)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs && \
npm install -g @mermaid-js/mermaid-cli 2>/dev/null || true && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy SmartTools from stage 1
COPY --from=smarttools /smarttools /smarttools
# Install SmartTools
RUN pip install --no-cache-dir -e /smarttools
# Copy Artifact Editor files
COPY pyproject.toml README.md ./
COPY src/ ./src/
COPY smarttools/ ./smarttools/
COPY install.sh ./
# Install Artifact Editor and dependencies
RUN pip install --no-cache-dir -e . && \
pip install --no-cache-dir PyQt6 pygments
# Create directories
RUN mkdir -p /root/.smarttools /root/.local/bin
# Install artifact SmartTools
RUN ./install.sh
# Install SmartTools example tools
RUN python /smarttools/examples/install.py 2>/dev/null || true && \
smarttools refresh 2>/dev/null || true
# Add local bin to PATH
ENV PATH="/root/.local/bin:${PATH}"
# Healthcheck - verify CLI tools work
RUN artifact-ai --help && \
artifact-export --help && \
smarttools list | head -5
# Default: show help
CMD ["artifact-ai", "--help"]
# ==============================================================================
# Usage Examples:
# ==============================================================================
# docker build -t artifact-editor .
#
# CLI (no display required):
# docker run -it --rm artifact-editor artifact-ai --format plantuml --instruction "Create class diagram"
# docker run -it --rm artifact-editor artifact-export --format plantuml --to /tmp/out.svg
# echo "@startuml\nA->B\n@enduml" | docker run -i --rm artifact-editor artifact-export --format plantuml --to /dev/stdout
#
# GUI (requires X11 forwarding):
# xhost +local:docker
# docker run -it --rm \
# -e DISPLAY=$DISPLAY \
# -e QT_QPA_PLATFORM=xcb \
# -v /tmp/.X11-unix:/tmp/.X11-unix \
# artifact-editor artifact-editor
#
# Interactive shell:
# docker run -it --rm artifact-editor bash

View File

@ -8,6 +8,7 @@ A standalone PyQt6-based editor for creating visual artifacts. Designed to integ
- **Split-view interface** - Code editor on the left, live preview on the right
- **Multi-format support** - PlantUML, Mermaid, OpenSCAD, Code, SVG, and Excalidraw
- **Format switching** - Change formats on the fly; file extension updates automatically
- **Interactive SVG editing** - Click to select, drag to move, resize with handles
- **Elements panel** - View and manage diagram elements
- **Visual editing** - Add, edit, and delete elements via dialogs or directly in preview
@ -16,6 +17,9 @@ A standalone PyQt6-based editor for creating visual artifacts. Designed to integ
- **Live preview** - See changes as you type (bidirectional sync)
- **Undo/Redo** - Works for both code and visual changes
- **3D controls** - Rotate/pan OpenSCAD models with mouse
- **AI-powered generation** - Describe what you want, AI generates the code
- **Voice input** - Dictation with visual countdown timer (10 seconds)
- **Save & Exit** - Quick toolbar button for integration workflows
## Supported Artifact Types
@ -134,18 +138,70 @@ artifact-editor/
│ ├── code.py # Pygments-based syntax highlighting
│ ├── svg.py # Direct SVG editing with UI components
│ └── excalidraw.py # Hand-drawn style diagrams
└── smarttools/ # CLI tools following Unix philosophy
├── artifact-ai/ # AI-powered artifact generation/modification
└── artifact-export/ # Render source to binary formats
```
## CLI SmartTools
In addition to the GUI, artifact-editor provides command-line SmartTools:
### artifact-ai
Generate or modify artifacts using AI:
```bash
# Create new artifact
echo "" | artifact-ai --format plantuml --instruction "Create a sequence diagram for user login"
# Modify existing artifact
cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer between API and database"
# Supported formats: plantuml, mermaid, openscad, svg, excalidraw, code
```
### artifact-export
Render artifacts to binary formats:
```bash
# PlantUML to SVG
cat diagram.puml | artifact-export --format plantuml --to output.svg
# OpenSCAD to PNG
cat model.scad | artifact-export --format openscad --to output.png
# Mermaid to PDF
cat flowchart.mmd | artifact-export --format mermaid --to output.pdf
```
Install SmartTools with `./install.sh` (requires SmartTools framework).
## Keyboard Shortcuts
- **Ctrl+S** - Save file
- **Ctrl+Z** - Undo (works for both code and visual edits)
- **Ctrl+Y** - Redo
- **Ctrl+R** - Force re-render preview
- **Delete** - Remove selected element (SVG mode)
- **Ctrl+Mouse wheel** - Zoom preview
- **Click+Drag** - Move elements in SVG preview
- **Corner handles** - Resize selected elements in SVG preview
## AI Input Panel
The bottom panel provides AI-assisted artifact creation:
- **Text input** - Describe what you want in natural language
- **Dictate button** - Voice input with 10-second countdown timer
- Shows `🎤 9s`, `🎤 8s`, etc. while recording
- Transcription appears in the input field for editing
- **AI Generate button** - Generates or modifies the artifact based on your description
- For new artifacts: Creates complete diagram code
- For existing content: Modifies based on instructions
- Handles retry with error context if generation fails
## Integration
The editor can be launched from other applications:

View File

@ -5,10 +5,12 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="$HOME/.local/bin"
SMARTTOOLS_DIR="$HOME/.smarttools"
mkdir -p "$INSTALL_DIR"
mkdir -p "$SMARTTOOLS_DIR"
# Create wrapper script
# Create wrapper script for artifact-editor GUI
cat > "$INSTALL_DIR/artifact-editor" << 'WRAPPER'
#!/bin/bash
# artifact-editor wrapper - launches PyQt6 artifact editor
@ -21,12 +23,36 @@ WRAPPER
sed -i "s|PLACEHOLDER_DIR|$SCRIPT_DIR|g" "$INSTALL_DIR/artifact-editor"
chmod +x "$INSTALL_DIR/artifact-editor"
# Install SmartTools
if [ -d "$SCRIPT_DIR/smarttools" ]; then
echo "Installing SmartTools..."
for tool in "$SCRIPT_DIR/smarttools"/*/; do
tool_name=$(basename "$tool")
cp -r "$tool" "$SMARTTOOLS_DIR/"
# Create CLI wrapper
cat > "$INSTALL_DIR/$tool_name" << WRAPPER
#!/bin/bash
# SmartTools wrapper for '$tool_name'
# Auto-generated
exec /usr/bin/python3 -m smarttools.runner $tool_name "\$@"
WRAPPER
chmod +x "$INSTALL_DIR/$tool_name"
echo " Installed: $tool_name"
done
fi
echo ""
echo "Installed artifact-editor to $INSTALL_DIR/artifact-editor"
echo ""
echo "Usage:"
echo " artifact-editor diagram.puml # Edit existing file"
echo " artifact-editor -o new.puml # Create new file"
echo ""
echo "SmartTools:"
echo " artifact-ai --format plantuml --instruction 'Create a class diagram'"
echo " artifact-export --format plantuml --to output.svg"
echo ""
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo "NOTE: Add $INSTALL_DIR to your PATH:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""

View File

@ -0,0 +1,165 @@
# artifact-ai - Generate or modify artifacts using AI
# Usage: cat diagram.puml | artifact-ai --format plantuml --instruction "Add a cache layer"
# Usage: echo "" | artifact-ai --format mermaid --instruction "Create a flowchart for login"
name: artifact-ai
description: Generate or modify artifacts (diagrams, 3D models, code) using AI
category: Artifact
arguments:
- flag: --format
variable: format
required: true
description: "Artifact format: plantuml, mermaid, openscad, svg, excalidraw, code"
- flag: --instruction
variable: instruction
required: true
description: Natural language instruction for generating or modifying the artifact
steps:
# Step 1: Build format-specific prompt and call AI
- type: code
output_var: prompt
code: |
import json
format_prompts = {
'plantuml': """You are a PlantUML expert. Create or modify PlantUML diagrams.
Supported diagram types:
- Sequence diagrams: actor, participant, ->, -->
- Class diagrams: class, interface, extends, implements
- Activity diagrams: start, stop, :action;, if/then/else
- Component diagrams: component, package, [interface]
- State diagrams: state, [*], -->
Always wrap with @startuml/@enduml.""",
'mermaid': """You are a Mermaid diagram expert. Create or modify Mermaid diagrams.
Supported diagram types:
- Flowcharts: graph TD/LR, A-->B
- Sequence: sequenceDiagram, Alice->>Bob
- Class: classDiagram, class ClassName
- State: stateDiagram-v2
- Entity Relationship: erDiagram
- Gantt: gantt, section, task
Start with the diagram type declaration (graph TD, sequenceDiagram, etc.).""",
'openscad': """You are an OpenSCAD 3D modeling expert. Create or modify parametric 3D models.
Core operations:
- Primitives: cube(), sphere(), cylinder(), polyhedron()
- Transformations: translate(), rotate(), scale(), mirror()
- Boolean: union(), difference(), intersection()
- 2D: circle(), square(), polygon(), linear_extrude(), rotate_extrude()
Use modules for reusable components. Use $fn for smoothness.""",
'svg': """You are an SVG expert. Create or modify vector graphics.
Core elements:
- Shapes: <rect>, <circle>, <ellipse>, <line>, <polyline>, <polygon>, <path>
- Text: <text>, <tspan>
- Groups: <g>, <defs>, <use>, <symbol>
- Styling: fill, stroke, stroke-width, opacity, transform
Include xmlns and viewBox. Use descriptive IDs.""",
'excalidraw': """You are an Excalidraw expert. Create hand-drawn style diagrams as JSON.
Structure:
- type: element type (rectangle, diamond, ellipse, arrow, line, text)
- x, y: position
- width, height: dimensions
- strokeColor, backgroundColor, fillStyle
- roughness: 0-2 (0=smooth, 2=sketchy)
Output valid JSON with "type": "excalidraw" and "elements" array.""",
'code': """You are a programming expert. Create or modify code in any language.
Focus on:
- Clean, readable code
- Proper language conventions
- Comments for complex logic only
- Error handling where appropriate
Detect the language from context or instruction."""
}
format_outputs = {
'plantuml': "Output ONLY valid PlantUML code starting with @startuml and ending with @enduml.",
'mermaid': "Output ONLY valid Mermaid code starting with the diagram type.",
'openscad': "Output ONLY valid OpenSCAD code with proper syntax.",
'svg': "Output ONLY valid SVG code starting with <?xml or <svg and ending with </svg>.",
'excalidraw': 'Output ONLY valid Excalidraw JSON starting with { and ending with }.',
'code': "Output ONLY the code, no markdown fences or explanations."
}
current_code = input.strip() if input.strip() else "(empty - create from scratch)"
format_guide = format_prompts.get(format, f"You are a {format} expert.")
output_instruction = format_outputs.get(format, "Output ONLY the code, no explanations.")
prompt = f"""{format_guide}
CRITICAL: {output_instruction}
Current code:
---
{current_code}
---
User request: {instruction}
{output_instruction}"""
# Don't print - just set the variable
result = prompt
- type: prompt
prompt: "{prompt}"
provider: claude
output_var: ai_output
# Step 2: Clean up output based on format
- type: code
output_var: result
code: |
import re
code = ai_output.strip()
# Remove markdown code blocks if present (three backticks)
fence_pattern = r'`{3}(?:\w+)?\n(.*?)`{3}'
match = re.search(fence_pattern, code, re.DOTALL)
if match:
code = match.group(1).strip()
# Format-specific cleanup
if format == 'svg':
# Remove any wrapper formats
code = re.sub(r'^@startuml\s*\n?', '', code)
code = re.sub(r'\n?@enduml\s*$', '', code)
# Find 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
elif format == 'excalidraw':
# Find JSON object
json_match = re.search(r'\{[\s\S]*\}', code)
if json_match:
code = json_match.group(0)
elif format == 'plantuml':
# Ensure proper tags
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'
print(code.strip())

View File

@ -0,0 +1,156 @@
# artifact-export - Render artifacts to binary formats
# Usage: cat diagram.puml | artifact-export --format plantuml --to /tmp/diagram.svg
# Usage: cat model.scad | artifact-export --format openscad --to /tmp/model.png
name: artifact-export
description: Render artifact source code to binary formats (PNG, SVG, PDF)
category: Artifact
arguments:
- flag: --format
variable: format
required: true
description: "Source format: plantuml, mermaid, openscad, svg, excalidraw, code"
- flag: --to
variable: output_path
required: true
description: Output file path (extension determines output format, e.g. .png, .svg, .pdf)
steps:
- type: code
output_var: result
code: |
import subprocess
import json
import os
from pathlib import Path
source_code = input.strip()
output = Path(output_path)
output_ext = output.suffix.lower()
# Ensure output directory exists
output.parent.mkdir(parents=True, exist_ok=True)
success = False
message = ""
if format == 'plantuml':
# Write to temp file
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.puml', delete=False) as f:
f.write(source_code)
temp_path = f.name
format_flag = {'.svg': '-tsvg', '.png': '-tpng', '.eps': '-teps', '.pdf': '-tpdf'}.get(output_ext, '-tsvg')
try:
result = subprocess.run(
['plantuml', format_flag, '-o', str(output.parent), temp_path],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0:
# PlantUML outputs to same name with different extension
temp_base = Path(temp_path)
generated = output.parent / (temp_base.stem + output_ext)
if generated.exists():
import shutil
shutil.move(str(generated), str(output))
success = True
message = str(output)
else:
message = f"Generated file not found: {generated}"
else:
message = f"PlantUML error: {result.stderr}"
finally:
os.unlink(temp_path)
elif format == 'mermaid':
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.mmd', delete=False) as f:
f.write(source_code)
temp_path = f.name
try:
result = subprocess.run(
['mmdc', '-i', temp_path, '-o', str(output)],
capture_output=True, text=True, timeout=30
)
if result.returncode == 0 and output.exists():
success = True
message = str(output)
else:
message = f"Mermaid error: {result.stderr}"
finally:
os.unlink(temp_path)
elif format == 'openscad':
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.scad', delete=False) as f:
f.write(source_code)
temp_path = f.name
try:
result = subprocess.run(
['openscad', '-o', str(output), temp_path],
capture_output=True, text=True, timeout=60
)
if result.returncode == 0 and output.exists():
success = True
message = str(output)
else:
message = f"OpenSCAD error: {result.stderr}"
finally:
os.unlink(temp_path)
elif format == 'svg':
if output_ext == '.svg':
output.write_text(source_code)
success = True
message = str(output)
elif output_ext == '.png':
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.svg', delete=False) as f:
f.write(source_code)
temp_path = f.name
try:
for cmd in [
['inkscape', temp_path, '-o', str(output)],
['rsvg-convert', temp_path, '-o', str(output)],
]:
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0 and output.exists():
success = True
message = str(output)
break
except FileNotFoundError:
continue
if not success:
message = "SVG to PNG requires inkscape or rsvg-convert"
finally:
os.unlink(temp_path)
else:
message = f"Unsupported output format: {output_ext}"
elif format == 'code':
output.write_text(source_code)
success = True
message = str(output)
elif format == 'excalidraw':
if output_ext == '.json':
output.write_text(source_code)
success = True
message = str(output)
else:
message = "Excalidraw export currently only supports .json output"
else:
message = f"Unknown format: {format}"
print(json.dumps({'success': success, 'path' if success else 'error': message}))

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,263 @@
"""Parser for Excalidraw JSON format."""
import json
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Tuple
@dataclass
class ExcalidrawElement:
"""Represents an Excalidraw element."""
element_type: str # rectangle, ellipse, diamond, line, arrow, text, freedraw
id: str
x: float = 0
y: float = 0
width: float = 100
height: float = 100
stroke_color: str = "#000000"
background_color: str = "transparent"
stroke_width: int = 1
roughness: int = 1
fill_style: str = "hachure"
text: str = "" # For text elements
font_size: int = 20
font_family: int = 1 # 1=hand-drawn, 2=normal, 3=monospace
points: List[List[float]] = field(default_factory=list) # For line/arrow/freedraw
# Position tracking for in-place replacement
raw_data: Dict[str, Any] = field(default_factory=dict)
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 in ('line', 'arrow'):
return f"{self.element_type.title()}"
else:
return f"{self.element_type.title()} ({self.width:.0f}x{self.height:.0f})"
def to_dict(self) -> Dict[str, Any]:
"""Convert to Excalidraw JSON element format."""
element = {
'type': self.element_type,
'id': self.id,
'x': self.x,
'y': self.y,
'width': self.width,
'height': self.height,
'strokeColor': self.stroke_color,
'backgroundColor': self.background_color,
'strokeWidth': self.stroke_width,
'roughness': self.roughness,
'fillStyle': self.fill_style,
'opacity': 100,
'angle': 0,
'isDeleted': False,
'version': 1,
}
if self.element_type == 'text':
element['text'] = self.text
element['fontSize'] = self.font_size
element['fontFamily'] = self.font_family
if self.element_type in ('line', 'arrow', 'freedraw'):
element['points'] = self.points if self.points else [[0, 0], [self.width, self.height]]
return element
def parse_excalidraw_elements(source: str) -> List[ExcalidrawElement]:
"""Parse Excalidraw JSON and return list of elements.
Args:
source: Excalidraw JSON string
Returns:
List of ExcalidrawElement objects
"""
if not source.strip():
return []
try:
data = json.loads(source)
except json.JSONDecodeError:
return []
# Handle different formats
if isinstance(data, list):
elements_data = data
else:
elements_data = data.get('elements', [])
elements = []
for el in elements_data:
if el.get('isDeleted', False):
continue
element = ExcalidrawElement(
element_type=el.get('type', 'rectangle'),
id=el.get('id', ''),
x=el.get('x', 0),
y=el.get('y', 0),
width=el.get('width', 100),
height=el.get('height', 100),
stroke_color=el.get('strokeColor', '#000000'),
background_color=el.get('backgroundColor', 'transparent'),
stroke_width=el.get('strokeWidth', 1),
roughness=el.get('roughness', 1),
fill_style=el.get('fillStyle', 'hachure'),
text=el.get('text', ''),
font_size=el.get('fontSize', 20),
font_family=el.get('fontFamily', 1),
points=el.get('points', []),
raw_data=el,
)
elements.append(element)
return elements
def get_excalidraw_document(source: str) -> Dict[str, Any]:
"""Parse and return the full Excalidraw document structure."""
if not source.strip():
return {'type': 'excalidraw', 'version': 2, 'elements': [], 'appState': {}}
try:
data = json.loads(source)
if isinstance(data, list):
return {'type': 'excalidraw', 'version': 2, 'elements': data, 'appState': {}}
return data
except json.JSONDecodeError:
return {'type': 'excalidraw', 'version': 2, 'elements': [], 'appState': {}}
def add_excalidraw_element(source: str, element: ExcalidrawElement) -> str:
"""Add a new element to the Excalidraw document.
Args:
source: Current Excalidraw JSON
element: Element to add
Returns:
Updated JSON string
"""
doc = get_excalidraw_document(source)
doc['elements'].append(element.to_dict())
return json.dumps(doc, indent=2)
def update_excalidraw_element(source: str, element_id: str, updated: ExcalidrawElement) -> str:
"""Update an existing element in the Excalidraw document.
Args:
source: Current Excalidraw JSON
element_id: ID of element to update
updated: Updated element data
Returns:
Updated JSON string
"""
doc = get_excalidraw_document(source)
for i, el in enumerate(doc['elements']):
if el.get('id') == element_id:
# Preserve some original properties
new_el = updated.to_dict()
new_el['id'] = element_id # Keep original ID
# Preserve version and increment
new_el['version'] = el.get('version', 1) + 1
doc['elements'][i] = new_el
break
return json.dumps(doc, indent=2)
def delete_excalidraw_element(source: str, element_id: str) -> str:
"""Delete an element from the Excalidraw document.
Args:
source: Current Excalidraw JSON
element_id: ID of element to delete
Returns:
Updated JSON string
"""
doc = get_excalidraw_document(source)
# Option 1: Remove completely
doc['elements'] = [el for el in doc['elements'] if el.get('id') != element_id]
# Option 2: Mark as deleted (preserves undo history)
# for el in doc['elements']:
# if el.get('id') == element_id:
# el['isDeleted'] = True
# break
return json.dumps(doc, indent=2)
def generate_element_id() -> str:
"""Generate a unique element ID."""
import uuid
return str(uuid.uuid4())[:8]
# Color palette for Excalidraw (matches their UI)
EXCALIDRAW_COLORS = {
'stroke': [
('#000000', 'Black'),
('#343a40', 'Dark Gray'),
('#495057', 'Gray'),
('#c92a2a', 'Red'),
('#a61e4d', 'Pink'),
('#862e9c', 'Grape'),
('#5f3dc4', 'Violet'),
('#364fc7', 'Indigo'),
('#1864ab', 'Blue'),
('#0b7285', 'Cyan'),
('#087f5b', 'Teal'),
('#2b8a3e', 'Green'),
('#5c940d', 'Lime'),
('#e67700', 'Yellow'),
('#d9480f', 'Orange'),
],
'background': [
('transparent', 'None'),
('#ffe3e3', 'Light Red'),
('#fcc2d7', 'Light Pink'),
('#eebefa', 'Light Grape'),
('#d0bfff', 'Light Violet'),
('#bac8ff', 'Light Indigo'),
('#a5d8ff', 'Light Blue'),
('#99e9f2', 'Light Cyan'),
('#96f2d7', 'Light Teal'),
('#b2f2bb', 'Light Green'),
('#d8f5a2', 'Light Lime'),
('#ffec99', 'Light Yellow'),
('#ffd8a8', 'Light Orange'),
('#ffffff', 'White'),
('#f8f9fa', 'Off White'),
],
}
EXCALIDRAW_ELEMENT_TYPES = [
('rectangle', 'Rectangle'),
('ellipse', 'Ellipse'),
('diamond', 'Diamond'),
('line', 'Line'),
('arrow', 'Arrow'),
('text', 'Text'),
]
FILL_STYLES = [
('hachure', 'Hachure'),
('cross-hatch', 'Cross-Hatch'),
('solid', 'Solid'),
]
FONT_FAMILIES = [
(1, 'Hand-drawn'),
(2, 'Normal'),
(3, 'Monospace'),
]

View File

@ -187,176 +187,52 @@ class RenderThread(QThread):
class DictateThread(QThread):
"""Background thread for voice dictation."""
finished = pyqtSignal(str) # transcribed text
tick = pyqtSignal(int) # seconds remaining
def __init__(self, duration: int = 10):
super().__init__()
self.duration = duration
def run(self):
import time
try:
dictate_path = Path.home() / ".local" / "bin" / "dictate"
if not dictate_path.exists():
self.finished.emit("[Error: dictate tool not found]")
return
result = subprocess.run(
# Start the dictate process
import subprocess as sp
proc = sp.Popen(
[str(dictate_path), "--duration", str(self.duration)],
capture_output=True, text=True, timeout=self.duration + 30
stdout=sp.PIPE, stderr=sp.PIPE, text=True
)
transcript = result.stdout.strip()
# Emit countdown ticks while waiting
for remaining in range(self.duration, 0, -1):
self.tick.emit(remaining)
time.sleep(1)
if proc.poll() is not None:
break # Process finished early
self.tick.emit(0)
# Wait for process to finish (with timeout)
try:
stdout, stderr = proc.communicate(timeout=5)
except sp.TimeoutExpired:
proc.kill()
stdout, stderr = proc.communicate()
transcript = stdout.strip()
if transcript:
self.finished.emit(transcript)
else:
self.finished.emit("[No speech detected]")
except subprocess.TimeoutExpired:
self.finished.emit("[Dictation timed out]")
except Exception as 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):
"""Background thread for AI generation with validation and retry."""
finished = pyqtSignal(bool, str) # success, result/error
@ -381,114 +257,29 @@ class AIThread(QThread):
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.")
def _call_ai(self, instruction: str, current_code: str) -> tuple:
"""Call artifact-ai SmartTool and return (success, response_or_error).
# 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.")
Uses the artifact-ai SmartTool which handles prompt building and cleanup.
"""
tool_path = Path.home() / ".local" / "bin" / "artifact-ai"
if retry_error and previous_output:
# Retry prompt with error feedback
return f"""{format_guide}
if tool_path.exists():
result = subprocess.run(
[str(tool_path), "--format", self.artifact_type, "--instruction", instruction],
input=current_code,
capture_output=True,
text=True,
timeout=120
)
if result.returncode == 0:
return True, result.stdout.strip()
else:
return False, f"artifact-ai error: {result.stderr}"
CRITICAL: {output_instruction}
Original request: {self.instruction}
Current code in editor:
```
{self.current_code}
```
Your previous output:
```
{previous_output}
```
However, validation failed with this error:
{retry_error}
Please fix the error and provide corrected output. {output_instruction}"""
else:
# Initial prompt
return f"""{format_guide}
CRITICAL: {output_instruction}
Current code:
```
{self.current_code}
```
User request: {self.instruction}
{output_instruction}"""
def _extract_code(self, response: str) -> str:
"""Extract code from AI response, handling markdown fences and errant wrappers."""
code = response.strip()
# Remove markdown code fences
if "```" in code:
match = re.search(r'```(?:\w+)?\n(.*?)```', code, re.DOTALL)
if match:
code = match.group(1).strip()
# For SVG: extract just the SVG content if wrapped in other formats
if self.artifact_type == 'svg':
# Remove any @startuml/@enduml wrappers
code = re.sub(r'^@startuml\s*\n?', '', code)
code = re.sub(r'\n?@enduml\s*$', '', code)
# Find the SVG content
svg_match = re.search(r'(<\?xml[^?]*\?>)?\s*(<svg[\s\S]*?</svg>)', code)
if svg_match:
xml_decl = svg_match.group(1) or ''
svg_content = svg_match.group(2)
code = (xml_decl + '\n' + svg_content).strip() if xml_decl else svg_content
# For Excalidraw: extract just the JSON
elif self.artifact_type == 'excalidraw':
# Find JSON object
json_match = re.search(r'\{[\s\S]*\}', code)
if json_match:
code = json_match.group(0)
# For PlantUML: ensure it has proper markers
elif self.artifact_type == 'plantuml':
if not code.strip().startswith('@start'):
code = '@startuml\n' + code
if not code.strip().endswith('@enduml') and '@enduml' not in code:
code = code + '\n@enduml'
return code.strip()
def _call_ai(self, prompt: str) -> tuple:
"""Call AI and return (success, response_or_error)."""
# Only use discussion-diagram-editor for PlantUML (it's PlantUML-specific)
if self.artifact_type == 'plantuml':
tool_path = Path.home() / ".local" / "bin" / "discussion-diagram-editor"
if tool_path.exists():
result = subprocess.run(
[str(tool_path), "--instruction", self.instruction],
input=self.current_code,
capture_output=True,
text=True,
timeout=60
)
if result.returncode == 0:
return True, result.stdout
# Use claude directly for all formats
# Fallback: Use claude directly if SmartTool not available
if subprocess.run(["which", "claude"], capture_output=True).returncode == 0:
prompt = f"You are a {self.artifact_type} expert. {instruction}\n\nCurrent code:\n{current_code}"
result = subprocess.run(
["claude", "-p", "--model", "sonnet"],
input=prompt,
@ -496,38 +287,32 @@ User request: {self.instruction}
text=True,
timeout=120
)
if result.returncode == 0:
return True, result.stdout
return True, result.stdout.strip()
return False, "No AI tools available (claude CLI not found)"
return False, "No AI tools available (artifact-ai SmartTool or claude CLI not found)"
def run(self):
try:
retry_count = 0
last_error = None
last_output = None
current_code = self.current_code
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:
# Build instruction (with error context if retrying)
if retry_count > 0 and last_error:
instruction = f"{self.instruction}\n\nPrevious attempt failed validation with: {last_error}\nPlease fix the error."
self.status_update.emit(f"Retry {retry_count}/{self.MAX_RETRIES}: fixing validation error...")
else:
instruction = self.instruction
# Call AI
success, response = self._call_ai(prompt)
# Call AI via SmartTool
success, code = self._call_ai(instruction, current_code)
if not success:
self.finished.emit(False, response)
self.finished.emit(False, code)
return
# Extract code from response
code = self._extract_code(response)
# Validate output
is_valid, error = self._validate_output(code)
@ -537,7 +322,7 @@ User request: {self.instruction}
else:
# Validation failed - prepare for retry
last_error = error
last_output = code
current_code = code # Use the generated code for retry context
retry_count += 1
if retry_count > self.MAX_RETRIES:
@ -958,10 +743,13 @@ class AIPanel(QWidget):
def _on_dictate(self):
self.dictate_requested.emit()
def set_dictating(self, is_dictating: bool):
def set_dictating(self, is_dictating: bool, seconds_remaining: int = 0):
"""Update UI state during dictation."""
self.dictate_btn.setEnabled(not is_dictating)
self.dictate_btn.setText("🎤 Listening..." if is_dictating else "🎤 Dictate")
if is_dictating:
self.dictate_btn.setText(f"🎤 {seconds_remaining}s")
else:
self.dictate_btn.setText("🎤 Dictate")
def set_ai_processing(self, is_processing: bool):
"""Update UI state during AI processing."""
@ -1619,15 +1407,15 @@ class ArtifactEditorWindow(QMainWindow):
toolbar.addSeparator()
# Action buttons
render_action = QAction("Render", self)
render_action.triggered.connect(self._render_preview)
toolbar.addAction(render_action)
# Action buttons (removed redundant Render - use Ctrl+R or button by preview)
save_action = QAction("Save", self)
save_action.triggered.connect(self._save)
toolbar.addAction(save_action)
save_exit_action = QAction("Save && Exit", self)
save_exit_action.triggered.connect(self._save_and_exit)
toolbar.addAction(save_exit_action)
def _connect_signals(self):
"""Connect widget signals to slots."""
self.code_editor.textChanged.connect(self._on_text_changed)
@ -1700,6 +1488,20 @@ class ArtifactEditorWindow(QMainWindow):
self.artifact_type = artifact_type
self.renderer = get_renderer(artifact_type)
# Update output_path extension to match new format
format_to_ext = {
'plantuml': '.puml',
'mermaid': '.mmd',
'svg': '.svg',
'openscad': '.scad',
'excalidraw': '.json',
'code': '.txt',
}
if artifact_type in format_to_ext:
new_ext = format_to_ext[artifact_type]
self.output_path = self.output_path.with_suffix(new_ext)
self._update_title()
# Show/hide 3D rotation controls and enable drag rotation for OpenSCAD
is_openscad = artifact_type == "openscad"
self.rotation_panel.setVisible(is_openscad)
@ -1862,12 +1664,17 @@ class ArtifactEditorWindow(QMainWindow):
if self.dictate_thread and self.dictate_thread.isRunning():
return
self.ai_panel.set_dictating(True)
self.ai_panel.set_dictating(True, 10)
self.dictate_thread = DictateThread(duration=10)
self.dictate_thread.tick.connect(self._on_dictate_tick)
self.dictate_thread.finished.connect(self._on_dictate_finished)
self.dictate_thread.start()
def _on_dictate_tick(self, seconds_remaining: int):
"""Update countdown during dictation."""
self.ai_panel.set_dictating(True, seconds_remaining)
def _on_dictate_finished(self, transcript: str):
"""Handle dictation completion."""
self.ai_panel.set_dictating(False)
@ -2548,6 +2355,13 @@ class ArtifactEditorWindow(QMainWindow):
except Exception as e:
QMessageBox.critical(self, "Save Error", f"Failed to save: {e}")
return False
return True
def _save_and_exit(self):
"""Save artifact and close the editor."""
if self._save():
self.close()
def _save_as(self):
"""Save artifact to a new path."""
@ -2776,8 +2590,10 @@ class ArtifactEditorWindow(QMainWindow):
)
if reply == QMessageBox.StandardButton.Save:
self._save()
event.accept()
if self._save():
event.accept()
else:
event.ignore() # Save failed, don't close
elif reply == QMessageBox.StandardButton.Discard:
event.accept()
else:

View File

@ -78,3 +78,6 @@ def list_renderers() -> list[str]:
from . import mermaid
from . import openscad
from . import plantuml
from . import code
from . import svg
from . import excalidraw

View File

@ -0,0 +1,452 @@
"""Code syntax highlighting renderer using Pygments."""
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple, List
from . import Renderer, register_renderer
try:
from pygments import highlight
from pygments.lexers import get_lexer_by_name, get_all_lexers, guess_lexer
from pygments.formatters import HtmlFormatter, SvgFormatter, ImageFormatter
from pygments.styles import get_all_styles, get_style_by_name
PYGMENTS_AVAILABLE = True
except ImportError:
PYGMENTS_AVAILABLE = False
# Popular languages to show at the top of the list
POPULAR_LANGUAGES = [
('python', 'Python'),
('javascript', 'JavaScript'),
('typescript', 'TypeScript'),
('html', 'HTML'),
('css', 'CSS'),
('json', 'JSON'),
('yaml', 'YAML'),
('bash', 'Bash/Shell'),
('sql', 'SQL'),
('java', 'Java'),
('c', 'C'),
('cpp', 'C++'),
('csharp', 'C#'),
('go', 'Go'),
('rust', 'Rust'),
('ruby', 'Ruby'),
('php', 'PHP'),
('swift', 'Swift'),
('kotlin', 'Kotlin'),
('scala', 'Scala'),
('r', 'R'),
('matlab', 'MATLAB'),
('markdown', 'Markdown'),
('xml', 'XML'),
('toml', 'TOML'),
('ini', 'INI'),
('dockerfile', 'Dockerfile'),
('makefile', 'Makefile'),
]
# Popular themes
POPULAR_THEMES = [
'monokai',
'dracula',
'github-dark',
'one-dark',
'nord',
'gruvbox-dark',
'solarized-dark',
'solarized-light',
'vs',
'friendly',
'default',
]
def is_available() -> bool:
"""Check if Pygments is available."""
return PYGMENTS_AVAILABLE
def get_languages() -> List[Tuple[str, str]]:
"""Get list of available languages as (id, name) tuples."""
if not PYGMENTS_AVAILABLE:
return []
# Start with popular languages
languages = list(POPULAR_LANGUAGES)
popular_ids = {lang[0] for lang in POPULAR_LANGUAGES}
# Add all other languages
all_lexers = []
for name, aliases, filetypes, mimetypes in get_all_lexers():
if aliases:
lexer_id = aliases[0]
if lexer_id not in popular_ids:
all_lexers.append((lexer_id, name))
# Sort the rest alphabetically by name
all_lexers.sort(key=lambda x: x[1].lower())
languages.extend(all_lexers)
return languages
def get_themes() -> List[str]:
"""Get list of available themes."""
if not PYGMENTS_AVAILABLE:
return ['default']
# Start with popular themes that exist
all_styles = set(get_all_styles())
themes = [t for t in POPULAR_THEMES if t in all_styles]
# Add remaining themes
for style in sorted(all_styles):
if style not in themes:
themes.append(style)
return themes
def render_to_html(
code: str,
language: str = 'python',
theme: str = 'monokai',
line_numbers: bool = True,
font_size: int = 14,
) -> str:
"""Render code to HTML with syntax highlighting.
Args:
code: Source code to highlight
language: Language identifier (e.g., 'python', 'javascript')
theme: Pygments style name
line_numbers: Whether to show line numbers
font_size: Font size in pixels
Returns:
Complete HTML document with styled code
"""
if not PYGMENTS_AVAILABLE:
return f"<pre><code>{code}</code></pre>"
try:
lexer = get_lexer_by_name(language)
except:
# Fall back to guessing or plain text
try:
lexer = guess_lexer(code)
except:
lexer = get_lexer_by_name('text')
formatter = HtmlFormatter(
style=theme,
linenos='table' if line_numbers else False,
cssclass='highlight',
full=False,
)
highlighted = highlight(code, lexer, formatter)
css = formatter.get_style_defs('.highlight')
# Build complete HTML
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {{
margin: 0;
padding: 16px;
background: {_get_bg_color(theme)};
font-family: 'Consolas', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: {font_size}px;
}}
.highlight {{
background: transparent !important;
}}
.highlight pre {{
margin: 0;
padding: 0;
overflow-x: auto;
}}
.linenodiv {{
padding-right: 12px;
border-right: 1px solid #444;
margin-right: 12px;
}}
.linenodiv pre {{
color: #666;
}}
table.highlighttable {{
border-spacing: 0;
}}
td.linenos {{
vertical-align: top;
padding-right: 10px;
}}
td.code {{
vertical-align: top;
width: 100%;
}}
{css}
</style>
</head>
<body>
{highlighted}
</body>
</html>"""
return html
def _get_bg_color(theme: str) -> str:
"""Get background color for a theme."""
if not PYGMENTS_AVAILABLE:
return '#1e1e1e'
try:
style = get_style_by_name(theme)
bg = style.background_color
return bg if bg else '#1e1e1e'
except:
return '#1e1e1e'
def render_to_svg(
code: str,
language: str = 'python',
theme: str = 'monokai',
line_numbers: bool = True,
font_size: int = 14,
) -> str:
"""Render code to SVG with syntax highlighting."""
if not PYGMENTS_AVAILABLE:
return f'<svg><text>{code}</text></svg>'
try:
lexer = get_lexer_by_name(language)
except:
try:
lexer = guess_lexer(code)
except:
lexer = get_lexer_by_name('text')
formatter = SvgFormatter(
style=theme,
linenos=line_numbers,
fontsize=f'{font_size}px',
)
return highlight(code, lexer, formatter)
def render_to_png(
code: str,
language: str = 'python',
theme: str = 'monokai',
line_numbers: bool = True,
font_size: int = 14,
output_path: Optional[str] = None,
) -> Optional[str]:
"""Render code to PNG image.
Requires Pillow to be installed.
Args:
code: Source code to highlight
language: Language identifier
theme: Pygments style name
line_numbers: Whether to show line numbers
font_size: Font size in pixels
output_path: Path to save PNG (uses temp file if None)
Returns:
Path to the generated PNG file, or None on error
"""
if not PYGMENTS_AVAILABLE:
return None
try:
from PIL import Image
except ImportError:
# Pillow not available, fall back to HTML screenshot or return None
return None
try:
lexer = get_lexer_by_name(language)
except:
try:
lexer = guess_lexer(code)
except:
lexer = get_lexer_by_name('text')
if output_path is None:
output_path = tempfile.mktemp(suffix='.png')
formatter = ImageFormatter(
style=theme,
linenos=line_numbers,
font_size=font_size,
line_number_bg='#2d2d2d',
line_number_fg='#666666',
)
try:
with open(output_path, 'wb') as f:
f.write(highlight(code, lexer, formatter))
return output_path
except Exception as e:
print(f"Error rendering PNG: {e}")
return None
def render(
code: str,
output_format: str = 'html',
language: str = 'python',
theme: str = 'monokai',
line_numbers: bool = True,
font_size: int = 14,
output_path: Optional[str] = None,
) -> Tuple[bool, str]:
"""Render code to the specified format.
Args:
code: Source code to highlight
output_format: Output format ('html', 'svg', 'png')
language: Language identifier
theme: Pygments style name
line_numbers: Whether to show line numbers
font_size: Font size
output_path: Path for output file
Returns:
Tuple of (success, result_or_error)
"""
if not PYGMENTS_AVAILABLE:
return False, "Pygments is not installed. Run: pip install pygments"
try:
if output_format == 'html':
html = render_to_html(code, language, theme, line_numbers, font_size)
if output_path:
Path(output_path).write_text(html)
return True, output_path
return True, html
elif output_format == 'svg':
svg = render_to_svg(code, language, theme, line_numbers, font_size)
if output_path:
Path(output_path).write_text(svg)
return True, output_path
return True, svg
elif output_format == 'png':
result = render_to_png(code, language, theme, line_numbers, font_size, output_path)
if result:
return True, result
return False, "PNG rendering failed. Make sure Pillow is installed."
else:
return False, f"Unknown output format: {output_format}"
except Exception as e:
return False, str(e)
def detect_language(code: str, filename: Optional[str] = None) -> str:
"""Try to detect the language of the code.
Args:
code: Source code
filename: Optional filename for better detection
Returns:
Language identifier
"""
if not PYGMENTS_AVAILABLE:
return 'text'
try:
if filename:
from pygments.lexers import get_lexer_for_filename
lexer = get_lexer_for_filename(filename, code)
else:
lexer = guess_lexer(code)
return lexer.aliases[0] if lexer.aliases else 'text'
except:
return 'text'
@register_renderer
class CodeRenderer(Renderer):
"""Renderer for syntax-highlighted code snippets."""
name = "code"
extensions = [".html", ".svg", ".png"]
def render(
self,
source: str,
output_path: Path,
code_options: Optional[dict] = None
) -> Tuple[bool, str]:
"""Render code to output file.
Args:
source: Source code to highlight
output_path: Where to save the rendered output
code_options: Optional dict with keys:
- language: Language identifier (default: 'python')
- theme: Pygments style name (default: 'monokai')
- line_numbers: Show line numbers (default: True)
- font_size: Font size in pixels (default: 14)
"""
ext = output_path.suffix.lower()
format_map = {
'.html': 'html',
'.svg': 'svg',
'.png': 'png',
}
output_format = format_map.get(ext, 'html')
# Extract options with defaults
options = code_options or {}
language = options.get('language', 'python')
theme = options.get('theme', 'monokai')
line_numbers = options.get('line_numbers', True)
font_size = options.get('font_size', 14)
return render(
source,
output_format=output_format,
language=language,
theme=theme,
line_numbers=line_numbers,
font_size=font_size,
output_path=str(output_path),
)
def validate(self, source: str) -> Tuple[bool, Optional[str]]:
"""Code is always valid - just text."""
return True, None
def get_template(self) -> str:
"""Return a starter template."""
return '''def hello_world():
"""A simple example function."""
print("Hello, World!")
if __name__ == "__main__":
hello_world()
'''
def check_available(self) -> Tuple[bool, str]:
"""Check if Pygments is available."""
if PYGMENTS_AVAILABLE:
return True, ""
return False, "Pygments is not installed. Run: pip install pygments"

View File

@ -0,0 +1,454 @@
"""Excalidraw renderer for hand-drawn style diagrams.
Excalidraw uses a JSON format that this renderer converts to SVG for preview.
The hand-drawn style is simulated using rough edges and imperfect lines.
"""
import json
import math
import random
import tempfile
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
from . import Renderer, register_renderer
def validate_excalidraw(source: str) -> Tuple[bool, Optional[str]]:
"""Validate Excalidraw JSON source.
Returns:
(is_valid, error_message)
"""
if not source.strip():
return False, "Empty content"
try:
data = json.loads(source)
# Check for required fields
if 'type' not in data:
# It might be a raw elements array or have elements directly
if 'elements' not in data and not isinstance(data, list):
return False, "Missing 'type' or 'elements' field"
return True, None
except json.JSONDecodeError as e:
return False, f"JSON parse error: {e}"
def parse_excalidraw(source: str) -> Dict[str, Any]:
"""Parse Excalidraw JSON and return normalized structure."""
data = json.loads(source)
# Handle different formats
if isinstance(data, list):
# Raw elements array
return {'elements': data, 'appState': {}}
if 'elements' not in data:
data['elements'] = []
if 'appState' not in data:
data['appState'] = {}
return data
def rough_line(x1: float, y1: float, x2: float, y2: float, roughness: float = 1.0) -> str:
"""Generate a rough/hand-drawn line path."""
# Add slight waviness to simulate hand-drawing
dx = x2 - x1
dy = y2 - y1
length = math.sqrt(dx * dx + dy * dy)
if length < 1:
return f"M {x1} {y1} L {x2} {y2}"
# Perpendicular offset direction
px = -dy / length
py = dx / length
# Create control points with small random offsets
segments = max(2, int(length / 30))
points = []
for i in range(segments + 1):
t = i / segments
x = x1 + dx * t
y = y1 + dy * t
if 0 < i < segments:
# Add roughness
offset = (random.random() - 0.5) * roughness * 2
x += px * offset
y += py * offset
points.append((x, y))
# Build path
path = f"M {points[0][0]:.1f} {points[0][1]:.1f}"
for x, y in points[1:]:
path += f" L {x:.1f} {y:.1f}"
return path
def rough_rect(x: float, y: float, width: float, height: float, roughness: float = 1.0) -> str:
"""Generate a rough/hand-drawn rectangle path."""
paths = [
rough_line(x, y, x + width, y, roughness),
rough_line(x + width, y, x + width, y + height, roughness),
rough_line(x + width, y + height, x, y + height, roughness),
rough_line(x, y + height, x, y, roughness),
]
return " ".join(paths)
def rough_ellipse(cx: float, cy: float, rx: float, ry: float, roughness: float = 1.0) -> str:
"""Generate a rough/hand-drawn ellipse path."""
points = []
segments = 24
for i in range(segments + 1):
angle = (i / segments) * 2 * math.pi
x = cx + rx * math.cos(angle)
y = cy + ry * math.sin(angle)
if i > 0 and i < segments:
# Add roughness perpendicular to the radius
offset = (random.random() - 0.5) * roughness * 2
x += offset * math.cos(angle)
y += offset * math.sin(angle)
points.append((x, y))
path = f"M {points[0][0]:.1f} {points[0][1]:.1f}"
for x, y in points[1:]:
path += f" L {x:.1f} {y:.1f}"
path += " Z"
return path
def rough_diamond(x: float, y: float, width: float, height: float, roughness: float = 1.0) -> str:
"""Generate a rough/hand-drawn diamond path."""
cx, cy = x + width / 2, y + height / 2
paths = [
rough_line(cx, y, x + width, cy, roughness),
rough_line(x + width, cy, cx, y + height, roughness),
rough_line(cx, y + height, x, cy, roughness),
rough_line(x, cy, cx, y, roughness),
]
return " ".join(paths)
def element_to_svg(element: Dict[str, Any], seed: int = 42) -> str:
"""Convert an Excalidraw element to SVG."""
random.seed(seed + hash(element.get('id', '')))
el_type = element.get('type', '')
x = element.get('x', 0)
y = element.get('y', 0)
width = element.get('width', 100)
height = element.get('height', 100)
stroke_color = element.get('strokeColor', '#000000')
bg_color = element.get('backgroundColor', 'transparent')
fill_style = element.get('fillStyle', 'hachure')
stroke_width = element.get('strokeWidth', 1)
roughness = element.get('roughness', 1)
# Handle fill
fill = 'none'
if bg_color != 'transparent':
fill = bg_color
# Common style for shapes with fill
style = f'stroke="{stroke_color}" stroke-width="{stroke_width}" fill="{fill}"'
# Style for lines (no fill)
line_style = f'stroke="{stroke_color}" stroke-width="{stroke_width}" fill="none"'
if el_type == 'rectangle':
path = rough_rect(x, y, width, height, roughness)
return f'<path d="{path}" {style}/>'
elif el_type == 'ellipse':
rx, ry = width / 2, height / 2
cx, cy = x + rx, y + ry
path = rough_ellipse(cx, cy, rx, ry, roughness)
return f'<path d="{path}" {style}/>'
elif el_type == 'diamond':
path = rough_diamond(x, y, width, height, roughness)
return f'<path d="{path}" {style}/>'
elif el_type == 'line' or el_type == 'arrow':
points = element.get('points', [[0, 0], [width, height]])
if len(points) < 2:
return ''
# Build path from points
path_parts = []
for i, point in enumerate(points):
px, py = x + point[0], y + point[1]
if i == 0:
path_parts.append(f"M {px:.1f} {py:.1f}")
else:
# Add roughness between points
prev = points[i - 1]
prev_x, prev_y = x + prev[0], y + prev[1]
rough_path = rough_line(prev_x, prev_y, px, py, roughness)
# Skip the M command from rough_line
path_parts.append(rough_path.split(' ', 3)[3] if ' ' in rough_path else f"L {px:.1f} {py:.1f}")
path = " ".join(path_parts)
result = f'<path d="{path}" {line_style}/>'
# Add arrowhead for arrows
if el_type == 'arrow' and len(points) >= 2:
end = points[-1]
prev = points[-2] if len(points) > 1 else [0, 0]
end_x, end_y = x + end[0], y + end[1]
dx = end[0] - prev[0]
dy = end[1] - prev[1]
length = math.sqrt(dx * dx + dy * dy)
if length > 0:
# Arrowhead points
arrow_size = 10
angle = math.atan2(dy, dx)
a1 = angle + math.pi * 0.8
a2 = angle - math.pi * 0.8
ax1 = end_x + arrow_size * math.cos(a1)
ay1 = end_y + arrow_size * math.sin(a1)
ax2 = end_x + arrow_size * math.cos(a2)
ay2 = end_y + arrow_size * math.sin(a2)
result += f'\n <path d="M {ax1:.1f} {ay1:.1f} L {end_x:.1f} {end_y:.1f} L {ax2:.1f} {ay2:.1f}" stroke="{stroke_color}" stroke-width="{stroke_width}" fill="none"/>'
return result
elif el_type == 'text':
text = element.get('text', '')
font_size = element.get('fontSize', 20)
font_family = element.get('fontFamily', 1) # 1 = hand-drawn, 2 = normal, 3 = monospace
font_map = {
1: "'Virgil', 'Comic Sans MS', cursive",
2: "'Helvetica', 'Arial', sans-serif",
3: "'Cascadia', 'Consolas', monospace",
}
font = font_map.get(font_family, font_map[1])
# Handle multi-line text
lines = text.split('\n')
result = f'<g transform="translate({x}, {y})">'
for i, line in enumerate(lines):
ty = font_size * (i + 1)
# Escape any special characters in the line
escaped_line = line.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
result += f'\n <text y="{ty}" font-family="{font}" font-size="{font_size}" fill="{stroke_color}">{escaped_line}</text>'
result += '\n</g>'
return result
elif el_type == 'freedraw':
points = element.get('points', [])
if len(points) < 2:
return ''
path = f"M {x + points[0][0]:.1f} {y + points[0][1]:.1f}"
for point in points[1:]:
path += f" L {x + point[0]:.1f} {y + point[1]:.1f}"
return f'<path d="{path}" stroke="{stroke_color}" stroke-width="{stroke_width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>'
return ''
def render_to_svg(source: str) -> str:
"""Render Excalidraw JSON to SVG."""
data = parse_excalidraw(source)
elements = data.get('elements', [])
if not elements:
return '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
<rect width="100%" height="100%" fill="#ffffff"/>
<text x="400" y="300" text-anchor="middle" font-family="sans-serif" fill="#999">Empty canvas</text>
</svg>'''
# Calculate bounds
min_x, min_y = float('inf'), float('inf')
max_x, max_y = float('-inf'), float('-inf')
for el in elements:
if el.get('isDeleted', False):
continue
x = el.get('x', 0)
y = el.get('y', 0)
w = el.get('width', 0)
h = el.get('height', 0)
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + w)
max_y = max(max_y, y + h)
# Add padding
padding = 50
min_x -= padding
min_y -= padding
max_x += padding
max_y += padding
width = max(max_x - min_x, 100)
height = max(max_y - min_y, 100)
# Build SVG
svg_parts = [
f'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{min_x:.0f} {min_y:.0f} {width:.0f} {height:.0f}" width="{width:.0f}" height="{height:.0f}">',
f' <rect x="{min_x}" y="{min_y}" width="{width}" height="{height}" fill="#ffffff"/>',
]
# Render elements (sorted by layer order if available)
sorted_elements = sorted(
[e for e in elements if not e.get('isDeleted', False)],
key=lambda e: (e.get('groupIds', []) or [''])[0] + str(e.get('id', ''))
)
for el in sorted_elements:
svg = element_to_svg(el)
if svg:
svg_parts.append(f' {svg}')
svg_parts.append('</svg>')
return '\n'.join(svg_parts)
def create_element(
element_type: str,
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 100,
**kwargs
) -> Dict[str, Any]:
"""Create an Excalidraw element dict."""
import uuid
element = {
'type': element_type,
'id': str(uuid.uuid4())[:8],
'x': x,
'y': y,
'width': width,
'height': height,
'strokeColor': kwargs.get('strokeColor', '#000000'),
'backgroundColor': kwargs.get('backgroundColor', 'transparent'),
'fillStyle': kwargs.get('fillStyle', 'hachure'),
'strokeWidth': kwargs.get('strokeWidth', 1),
'roughness': kwargs.get('roughness', 1),
'opacity': kwargs.get('opacity', 100),
'angle': kwargs.get('angle', 0),
'isDeleted': False,
'version': 1,
}
if element_type == 'text':
element['text'] = kwargs.get('text', 'Text')
element['fontSize'] = kwargs.get('fontSize', 20)
element['fontFamily'] = kwargs.get('fontFamily', 1)
if element_type in ('line', 'arrow'):
element['points'] = kwargs.get('points', [[0, 0], [width, height]])
element['width'] = 0
element['height'] = 0
if element_type == 'freedraw':
element['points'] = kwargs.get('points', [])
return element
def create_excalidraw_doc(elements: List[Dict[str, Any]], app_state: Dict[str, Any] = None) -> Dict[str, Any]:
"""Create a full Excalidraw document."""
return {
'type': 'excalidraw',
'version': 2,
'source': 'artifact-editor',
'elements': elements,
'appState': app_state or {
'viewBackgroundColor': '#ffffff',
'currentItemStrokeColor': '#000000',
'currentItemBackgroundColor': 'transparent',
},
}
@register_renderer
class ExcalidrawRenderer(Renderer):
"""Renderer for Excalidraw hand-drawn diagrams."""
name = "excalidraw"
extensions = [".excalidraw", ".svg", ".png"]
def render(self, source: str, output_path: Path) -> Tuple[bool, str]:
"""Render Excalidraw to output file."""
# Validate
is_valid, error = validate_excalidraw(source)
if not is_valid:
return False, error or "Invalid Excalidraw format"
ext = output_path.suffix.lower()
try:
svg_content = render_to_svg(source)
if ext == '.svg':
output_path.write_text(svg_content)
return True, str(output_path)
elif ext == '.excalidraw':
# Save as Excalidraw JSON
output_path.write_text(source)
return True, str(output_path)
elif ext == '.png':
# Try to convert SVG to PNG
from .svg import svg_to_png
if svg_to_png(svg_content, str(output_path)):
return True, str(output_path)
else:
# Fallback: save as SVG
svg_path = output_path.with_suffix('.svg')
svg_path.write_text(svg_content)
return True, str(svg_path)
return False, f"Unsupported format: {ext}"
except Exception as e:
return False, str(e)
def validate(self, source: str) -> Tuple[bool, Optional[str]]:
"""Validate Excalidraw JSON syntax."""
return validate_excalidraw(source)
def get_template(self) -> str:
"""Return a starter Excalidraw template."""
elements = [
create_element('rectangle', 50, 50, 200, 100, strokeColor='#1e88e5', backgroundColor='#e3f2fd'),
create_element('text', 100, 90, text='Hello!', fontSize=24, strokeColor='#1e88e5'),
create_element('ellipse', 300, 50, 120, 100, strokeColor='#43a047', backgroundColor='#e8f5e9'),
create_element('arrow', 260, 100, points=[[0, 0], [30, 0]], strokeColor='#666666'),
]
doc = create_excalidraw_doc(elements)
return json.dumps(doc, indent=2)
def check_available(self) -> Tuple[bool, str]:
"""Excalidraw is always available (pure Python rendering)."""
return True, ""

View File

@ -0,0 +1,530 @@
"""SVG renderer for direct vector graphics editing."""
import re
import tempfile
import subprocess
from pathlib import Path
from typing import Optional, Tuple, List, Dict, Any
from xml.etree import ElementTree as ET
from . import Renderer, register_renderer
def validate_svg(source: str) -> Tuple[bool, Optional[str]]:
"""Validate SVG source.
Returns:
(is_valid, error_message)
"""
if not source.strip():
return False, "Empty SVG"
# Check for SVG tag
if '<svg' not in source.lower():
return False, "Missing <svg> element"
try:
# Try to parse as XML
ET.fromstring(source)
return True, None
except ET.ParseError as e:
return False, f"XML parse error: {e}"
def create_element(
element_type: str,
x: float = 0,
y: float = 0,
width: float = 100,
height: float = 50,
**attrs
) -> str:
"""Create an SVG element.
Args:
element_type: rect, circle, ellipse, line, text, path, etc.
x, y: Position
width, height: Size (for rect, ellipse)
**attrs: Additional attributes (fill, stroke, etc.)
Returns:
SVG element string
"""
if element_type == 'rect':
base = f'<rect x="{x}" y="{y}" width="{width}" height="{height}"'
elif element_type == 'circle':
r = width / 2
base = f'<circle cx="{x + r}" cy="{y + r}" r="{r}"'
elif element_type == 'ellipse':
rx, ry = width / 2, height / 2
base = f'<ellipse cx="{x + rx}" cy="{y + ry}" rx="{rx}" ry="{ry}"'
elif element_type == 'line':
x2 = attrs.pop('x2', x + width)
y2 = attrs.pop('y2', y + height)
base = f'<line x1="{x}" y1="{y}" x2="{x2}" y2="{y2}"'
elif element_type == 'text':
text_content = attrs.pop('text', 'Text')
base_attrs = f'x="{x}" y="{y}"'
for key, val in attrs.items():
base_attrs += f' {key}="{val}"'
return f'<text {base_attrs}>{text_content}</text>'
elif element_type == 'path':
d = attrs.pop('d', f'M {x} {y} L {x+width} {y+height}')
base = f'<path d="{d}"'
elif element_type == 'polygon':
points = attrs.pop('points', f'{x},{y} {x+width},{y} {x+width},{y+height} {x},{y+height}')
base = f'<polygon points="{points}"'
elif element_type == 'polyline':
points = attrs.pop('points', f'{x},{y} {x+width/2},{y+height} {x+width},{y}')
base = f'<polyline points="{points}"'
else:
base = f'<{element_type}'
# Add remaining attributes
for key, val in attrs.items():
key = key.replace('_', '-') # stroke_width -> stroke-width
base += f' {key}="{val}"'
base += '/>'
return base
def create_group(elements: List[str], **attrs) -> str:
"""Create an SVG group (<g>) containing multiple elements."""
attr_str = ''
for key, val in attrs.items():
key = key.replace('_', '-')
attr_str += f' {key}="{val}"'
content = '\n '.join(elements)
return f'<g{attr_str}>\n {content}\n</g>'
# UI Wireframe Components
def create_button(
x: float, y: float,
text: str = "Button",
width: float = 100,
height: float = 36,
style: str = "primary"
) -> str:
"""Create a button wireframe element."""
styles = {
'primary': {'fill': '#3b82f6', 'text_fill': '#ffffff'},
'secondary': {'fill': '#6b7280', 'text_fill': '#ffffff'},
'outline': {'fill': 'none', 'stroke': '#3b82f6', 'text_fill': '#3b82f6'},
'ghost': {'fill': 'none', 'text_fill': '#3b82f6'},
}
s = styles.get(style, styles['primary'])
elements = [
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="4" '
f'fill="{s.get("fill", "none")}" stroke="{s.get("stroke", "none")}"/>',
f'<text x="{x + width/2}" y="{y + height/2 + 5}" '
f'text-anchor="middle" font-family="sans-serif" font-size="14" '
f'fill="{s["text_fill"]}">{text}</text>'
]
return create_group(elements, id=f'button-{text.lower().replace(" ", "-")}')
def create_input(
x: float, y: float,
placeholder: str = "Enter text...",
width: float = 200,
height: float = 40,
label: str = ""
) -> str:
"""Create an input field wireframe element."""
elements = []
label_offset = 0
if label:
elements.append(
f'<text x="{x}" y="{y - 8}" font-family="sans-serif" '
f'font-size="12" fill="#374151">{label}</text>'
)
elements.extend([
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="4" '
f'fill="#ffffff" stroke="#d1d5db" stroke-width="1"/>',
f'<text x="{x + 12}" y="{y + height/2 + 5}" '
f'font-family="sans-serif" font-size="14" fill="#9ca3af">{placeholder}</text>'
])
return create_group(elements, id=f'input-{label.lower().replace(" ", "-") or "field"}')
def create_card(
x: float, y: float,
title: str = "Card Title",
content: str = "Card content goes here",
width: float = 300,
height: float = 150
) -> str:
"""Create a card wireframe element."""
elements = [
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="8" '
f'fill="#ffffff" stroke="#e5e7eb" stroke-width="1"/>',
f'<text x="{x + 16}" y="{y + 28}" font-family="sans-serif" '
f'font-size="16" font-weight="bold" fill="#111827">{title}</text>',
f'<line x1="{x}" y1="{y + 44}" x2="{x + width}" y2="{y + 44}" '
f'stroke="#e5e7eb" stroke-width="1"/>',
f'<text x="{x + 16}" y="{y + 70}" font-family="sans-serif" '
f'font-size="14" fill="#6b7280">{content}</text>'
]
return create_group(elements, id=f'card-{title.lower().replace(" ", "-")}')
def create_navbar(
x: float, y: float,
brand: str = "Logo",
links: List[str] = None,
width: float = 800,
height: float = 60
) -> str:
"""Create a navigation bar wireframe."""
links = links or ["Home", "About", "Contact"]
elements = [
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" '
f'fill="#1f2937"/>',
f'<text x="{x + 20}" y="{y + height/2 + 6}" font-family="sans-serif" '
f'font-size="20" font-weight="bold" fill="#ffffff">{brand}</text>'
]
# Add nav links
link_x = x + width - 20
for link in reversed(links):
link_x -= len(link) * 10 + 20
elements.append(
f'<text x="{link_x}" y="{y + height/2 + 5}" font-family="sans-serif" '
f'font-size="14" fill="#d1d5db">{link}</text>'
)
return create_group(elements, id='navbar')
def create_modal(
x: float, y: float,
title: str = "Modal Title",
width: float = 400,
height: float = 250
) -> str:
"""Create a modal dialog wireframe."""
elements = [
# Backdrop (semi-transparent)
f'<rect x="{x-50}" y="{y-30}" width="{width+100}" height="{height+60}" '
f'fill="#000000" fill-opacity="0.3"/>',
# Modal box
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="8" '
f'fill="#ffffff" stroke="#e5e7eb"/>',
# Header
f'<text x="{x + 20}" y="{y + 30}" font-family="sans-serif" '
f'font-size="18" font-weight="bold" fill="#111827">{title}</text>',
# Close button
f'<text x="{x + width - 30}" y="{y + 30}" font-family="sans-serif" '
f'font-size="20" fill="#6b7280" cursor="pointer">×</text>',
# Divider
f'<line x1="{x}" y1="{y + 50}" x2="{x + width}" y2="{y + 50}" '
f'stroke="#e5e7eb"/>',
# Content area placeholder
f'<text x="{x + 20}" y="{y + 90}" font-family="sans-serif" '
f'font-size="14" fill="#6b7280">Modal content...</text>',
# Footer buttons
f'<rect x="{x + width - 180}" y="{y + height - 50}" width="70" height="32" '
f'rx="4" fill="none" stroke="#d1d5db"/>',
f'<text x="{x + width - 145}" y="{y + height - 28}" text-anchor="middle" '
f'font-family="sans-serif" font-size="14" fill="#374151">Cancel</text>',
f'<rect x="{x + width - 100}" y="{y + height - 50}" width="80" height="32" '
f'rx="4" fill="#3b82f6"/>',
f'<text x="{x + width - 60}" y="{y + height - 28}" text-anchor="middle" '
f'font-family="sans-serif" font-size="14" fill="#ffffff">Confirm</text>',
]
return create_group(elements, id='modal')
def create_icon(
x: float, y: float,
icon_type: str = "placeholder",
size: float = 24
) -> str:
"""Create an icon placeholder."""
icons = {
'placeholder': f'<rect x="{x}" y="{y}" width="{size}" height="{size}" '
f'rx="2" fill="#e5e7eb" stroke="#9ca3af" stroke-dasharray="2,2"/>',
'user': f'<circle cx="{x + size/2}" cy="{y + size/3}" r="{size/4}" fill="#9ca3af"/>'
f'<path d="M {x} {y + size} Q {x + size/2} {y + size/2} {x + size} {y + size}" '
f'fill="#9ca3af"/>',
'menu': f'<line x1="{x}" y1="{y + size/4}" x2="{x + size}" y2="{y + size/4}" '
f'stroke="#6b7280" stroke-width="2"/>'
f'<line x1="{x}" y1="{y + size/2}" x2="{x + size}" y2="{y + size/2}" '
f'stroke="#6b7280" stroke-width="2"/>'
f'<line x1="{x}" y1="{y + 3*size/4}" x2="{x + size}" y2="{y + 3*size/4}" '
f'stroke="#6b7280" stroke-width="2"/>',
'search': f'<circle cx="{x + size/3}" cy="{y + size/3}" r="{size/4}" '
f'fill="none" stroke="#6b7280" stroke-width="2"/>'
f'<line x1="{x + size/2}" y1="{y + size/2}" '
f'x2="{x + size}" y2="{y + size}" stroke="#6b7280" stroke-width="2"/>',
}
return icons.get(icon_type, icons['placeholder'])
def create_checkbox(
x: float, y: float,
label: str = "Option",
checked: bool = False,
size: float = 20
) -> str:
"""Create a checkbox wireframe."""
elements = [
f'<rect x="{x}" y="{y}" width="{size}" height="{size}" rx="3" '
f'fill="{"#3b82f6" if checked else "#ffffff"}" stroke="#d1d5db"/>',
]
if checked:
elements.append(
f'<path d="M {x+4} {y+size/2} L {x+size/2-1} {y+size-5} L {x+size-4} {y+4}" '
f'fill="none" stroke="#ffffff" stroke-width="2"/>'
)
elements.append(
f'<text x="{x + size + 8}" y="{y + size/2 + 5}" font-family="sans-serif" '
f'font-size="14" fill="#374151">{label}</text>'
)
return create_group(elements)
def create_radio(
x: float, y: float,
label: str = "Option",
selected: bool = False,
size: float = 20
) -> str:
"""Create a radio button wireframe."""
r = size / 2
elements = [
f'<circle cx="{x + r}" cy="{y + r}" r="{r}" '
f'fill="#ffffff" stroke="#d1d5db"/>',
]
if selected:
elements.append(
f'<circle cx="{x + r}" cy="{y + r}" r="{r * 0.5}" fill="#3b82f6"/>'
)
elements.append(
f'<text x="{x + size + 8}" y="{y + size/2 + 5}" font-family="sans-serif" '
f'font-size="14" fill="#374151">{label}</text>'
)
return create_group(elements)
def create_toggle(
x: float, y: float,
label: str = "",
on: bool = False,
width: float = 44,
height: float = 24
) -> str:
"""Create a toggle switch wireframe."""
r = height / 2 - 2
knob_x = x + width - r - 4 if on else x + r + 4
elements = [
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="{height/2}" '
f'fill="{"#3b82f6" if on else "#d1d5db"}"/>',
f'<circle cx="{knob_x}" cy="{y + height/2}" r="{r}" fill="#ffffff"/>',
]
if label:
elements.append(
f'<text x="{x + width + 10}" y="{y + height/2 + 5}" font-family="sans-serif" '
f'font-size="14" fill="#374151">{label}</text>'
)
return create_group(elements)
def create_avatar(
x: float, y: float,
initials: str = "",
size: float = 40
) -> str:
"""Create an avatar placeholder."""
elements = [
f'<circle cx="{x + size/2}" cy="{y + size/2}" r="{size/2}" fill="#e5e7eb"/>',
]
if initials:
elements.append(
f'<text x="{x + size/2}" y="{y + size/2 + 5}" text-anchor="middle" '
f'font-family="sans-serif" font-size="{size/2.5}" fill="#6b7280">{initials[:2].upper()}</text>'
)
else:
# User icon
elements.append(
f'<circle cx="{x + size/2}" cy="{y + size/3}" r="{size/5}" fill="#9ca3af"/>'
)
elements.append(
f'<ellipse cx="{x + size/2}" cy="{y + size}" rx="{size/3}" ry="{size/4}" fill="#9ca3af"/>'
)
return create_group(elements)
def create_progress(
x: float, y: float,
progress: float = 0.6,
width: float = 200,
height: float = 8
) -> str:
"""Create a progress bar wireframe."""
elements = [
f'<rect x="{x}" y="{y}" width="{width}" height="{height}" rx="{height/2}" fill="#e5e7eb"/>',
f'<rect x="{x}" y="{y}" width="{width * progress}" height="{height}" rx="{height/2}" fill="#3b82f6"/>',
]
return create_group(elements)
def wrap_svg(
content: str,
width: int = 800,
height: int = 600,
background: str = "#f9fafb"
) -> str:
"""Wrap content in a complete SVG document."""
return f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}">
<rect width="100%" height="100%" fill="{background}"/>
{content}
</svg>'''
def svg_to_png(svg_content: str, output_path: str) -> bool:
"""Convert SVG to PNG using available tools."""
# Try different conversion methods
# Method 1: Use Inkscape
try:
result = subprocess.run(
['which', 'inkscape'],
capture_output=True,
timeout=5
)
if result.returncode == 0:
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='w') as f:
f.write(svg_content)
temp_svg = f.name
subprocess.run([
'inkscape', temp_svg,
'--export-type=png',
f'--export-filename={output_path}'
], capture_output=True, timeout=30)
Path(temp_svg).unlink(missing_ok=True)
if Path(output_path).exists():
return True
except:
pass
# Method 2: Use rsvg-convert
try:
result = subprocess.run(
['which', 'rsvg-convert'],
capture_output=True,
timeout=5
)
if result.returncode == 0:
with tempfile.NamedTemporaryFile(suffix='.svg', delete=False, mode='w') as f:
f.write(svg_content)
temp_svg = f.name
subprocess.run([
'rsvg-convert', '-o', output_path, temp_svg
], capture_output=True, timeout=30)
Path(temp_svg).unlink(missing_ok=True)
if Path(output_path).exists():
return True
except:
pass
# Method 3: Use cairosvg Python library
try:
import cairosvg
cairosvg.svg2png(bytestring=svg_content.encode(), write_to=output_path)
return True
except ImportError:
pass
except Exception:
pass
return False
@register_renderer
class SVGRenderer(Renderer):
"""Renderer for direct SVG graphics."""
name = "svg"
extensions = [".svg", ".png"]
def render(self, source: str, output_path: Path) -> Tuple[bool, str]:
"""Render SVG to output file."""
# Validate
is_valid, error = validate_svg(source)
if not is_valid:
return False, error or "Invalid SVG"
ext = output_path.suffix.lower()
if ext == '.svg':
# Just write the SVG
output_path.write_text(source)
return True, str(output_path)
elif ext == '.png':
# Convert to PNG
if svg_to_png(source, str(output_path)):
return True, str(output_path)
else:
# Fallback: save as SVG and let the viewer handle it
svg_path = output_path.with_suffix('.svg')
svg_path.write_text(source)
return True, str(svg_path)
return False, f"Unsupported format: {ext}"
def validate(self, source: str) -> Tuple[bool, Optional[str]]:
"""Validate SVG syntax."""
return validate_svg(source)
def get_template(self) -> str:
"""Return a starter SVG template."""
return '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
<!-- Background -->
<rect width="100%" height="100%" fill="#f9fafb"/>
<!-- Your shapes here -->
<rect x="50" y="50" width="200" height="100" rx="8" fill="#3b82f6"/>
<text x="150" y="110" text-anchor="middle" font-family="sans-serif"
font-size="18" fill="white">Hello SVG!</text>
<circle cx="400" cy="200" r="60" fill="#10b981"/>
<line x1="500" y1="100" x2="700" y2="250" stroke="#6366f1" stroke-width="3"/>
</svg>'''
def check_available(self) -> Tuple[bool, str]:
"""SVG is always available (just XML)."""
return True, ""
# Export UI component creators
UI_COMPONENTS = {
'button': create_button,
'input': create_input,
'card': create_card,
'navbar': create_navbar,
'modal': create_modal,
'icon': create_icon,
'checkbox': create_checkbox,
'radio': create_radio,
'toggle': create_toggle,
'avatar': create_avatar,
'progress': create_progress,
}

View File

@ -0,0 +1,575 @@
"""Interactive SVG selection using QGraphicsScene items.
This module provides scene-based selection that naturally scrolls and zooms
with the content, avoiding coordinate transformation issues.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional, List
from PyQt6.QtCore import Qt, QRectF, QPointF, pyqtSignal, QSizeF, QObject
from PyQt6.QtGui import QPen, QBrush, QColor, QCursor
from PyQt6.QtWidgets import (
QGraphicsScene, QGraphicsView, QGraphicsRectItem,
QGraphicsItem, QMenu, QGraphicsSceneMouseEvent
)
from .svg_parser import SVGElement, parse_svg_elements
class HandlePosition(Enum):
"""Position of resize handles."""
TOP_LEFT = "tl"
TOP = "t"
TOP_RIGHT = "tr"
RIGHT = "r"
BOTTOM_RIGHT = "br"
BOTTOM = "b"
BOTTOM_LEFT = "bl"
LEFT = "l"
class ResizeHandle(QGraphicsRectItem):
"""A resize handle for selected elements."""
HANDLE_SIZE = 8
def __init__(self, position: HandlePosition, parent=None):
super().__init__(parent)
self.position = position
self.setRect(-self.HANDLE_SIZE/2, -self.HANDLE_SIZE/2,
self.HANDLE_SIZE, self.HANDLE_SIZE)
self.setPen(QPen(QColor(0, 120, 215), 1))
self.setBrush(QBrush(QColor(255, 255, 255)))
self.setZValue(1001) # Above selection rect
self.setAcceptHoverEvents(True)
# Set cursor based on handle position
cursors = {
HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor,
HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor,
HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor,
HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor,
HandlePosition.TOP: Qt.CursorShape.SizeVerCursor,
HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor,
HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor,
HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor,
}
self.setCursor(cursors[position])
class SelectionRect(QGraphicsRectItem):
"""Selection rectangle with resize handles."""
def __init__(self, parent=None):
super().__init__(parent)
self.setPen(QPen(QColor(0, 120, 215), 2, Qt.PenStyle.DashLine))
self.setBrush(QBrush(Qt.BrushStyle.NoBrush))
self.setZValue(1000) # Above content
# Create resize handles
self.handles = {}
for pos in HandlePosition:
handle = ResizeHandle(pos, self)
self.handles[pos] = handle
self.setVisible(False)
def update_handles(self):
"""Position handles at the corners and edges of the rect."""
rect = self.rect()
cx, cy = rect.center().x(), rect.center().y()
positions = {
HandlePosition.TOP_LEFT: (rect.left(), rect.top()),
HandlePosition.TOP: (cx, rect.top()),
HandlePosition.TOP_RIGHT: (rect.right(), rect.top()),
HandlePosition.RIGHT: (rect.right(), cy),
HandlePosition.BOTTOM_RIGHT: (rect.right(), rect.bottom()),
HandlePosition.BOTTOM: (cx, rect.bottom()),
HandlePosition.BOTTOM_LEFT: (rect.left(), rect.bottom()),
HandlePosition.LEFT: (rect.left(), cy),
}
for pos, (x, y) in positions.items():
self.handles[pos].setPos(x, y)
@dataclass
class ElementInfo:
"""Cached info about an SVG element."""
element: SVGElement
scene_rect: QRectF # Bounding box in scene coordinates
class SVGSelectionManager(QObject):
"""Manages SVG element selection in a QGraphicsScene.
This uses scene-based graphics items for selection, which naturally
scroll and zoom with the content.
"""
# Signals
element_selected = pyqtSignal(object) # SVGElement or None
element_moved = pyqtSignal(object, float, float) # element, new_x, new_y
element_resized = pyqtSignal(object, float, float, float, float) # element, x, y, w, h
# Context menu signals
bring_to_front = pyqtSignal(object)
send_to_back = pyqtSignal(object)
bring_forward = pyqtSignal(object)
send_backward = pyqtSignal(object)
add_to_group = pyqtSignal(object, str)
remove_from_group = pyqtSignal(object)
create_group = pyqtSignal(object)
delete_element = pyqtSignal(object)
def __init__(self, scene: QGraphicsScene, view: QGraphicsView):
super().__init__()
self.scene = scene
self.view = view
# Enabled state - only process events when True
self._enabled = False
# SVG coordinate system
self.view_box = QRectF(0, 0, 800, 600)
self.pixmap_size = QSizeF(800, 600)
# Element tracking
self.elements: List[ElementInfo] = []
self.selected_element: Optional[SVGElement] = None
# Selection graphics (created but not added to scene until enabled)
self.selection_rect = SelectionRect()
# Interaction state
self.dragging = False
self.resizing = False
self.active_handle: Optional[HandlePosition] = None
self.drag_start_scene: Optional[QPointF] = None
self.drag_start_svg: Optional[QPointF] = None
self.element_start_rect: Optional[QRectF] = None
# Groups for context menu
self.groups: List[str] = []
# Install event filter on view's viewport
self.view.viewport().installEventFilter(self)
def set_enabled(self, enabled: bool):
"""Enable or disable the selection manager."""
self._enabled = enabled
if enabled:
# Add selection rect to scene if not already there
if self.selection_rect.scene() != self.scene:
self.scene.addItem(self.selection_rect)
else:
# Remove selection rect from scene and clear selection
if self.selection_rect.scene():
self.scene.removeItem(self.selection_rect)
self.clear_selection()
def is_enabled(self) -> bool:
"""Check if selection manager is enabled."""
return self._enabled
def set_coordinate_system(self, view_box: QRectF, pixmap_size: QSizeF):
"""Set the SVG coordinate system mapping."""
self.view_box = view_box
self.pixmap_size = pixmap_size
# Update selection if active
if self.selected_element:
self._update_selection_rect()
def update_elements(self, svg_source: str):
"""Parse SVG and update element list."""
self.elements = []
parsed = parse_svg_elements(svg_source)
for elem in parsed:
scene_rect = self._get_element_scene_rect(elem)
if scene_rect:
self.elements.append(ElementInfo(element=elem, scene_rect=scene_rect))
# Update selection rect if element still exists
if self.selected_element:
found = False
for info in self.elements:
if info.element.id == self.selected_element.id:
self.selected_element = info.element
self._update_selection_rect()
found = True
break
if not found:
self.clear_selection()
def set_groups(self, groups: List[str]):
"""Update available groups for context menu."""
self.groups = groups
def _svg_to_scene(self, svg_x: float, svg_y: float) -> QPointF:
"""Convert SVG coordinates to scene coordinates."""
if self.view_box.width() == 0 or self.view_box.height() == 0:
return QPointF(svg_x, svg_y)
# Scale from viewBox to pixmap
scale_x = self.pixmap_size.width() / self.view_box.width()
scale_y = self.pixmap_size.height() / self.view_box.height()
scene_x = (svg_x - self.view_box.x()) * scale_x
scene_y = (svg_y - self.view_box.y()) * scale_y
return QPointF(scene_x, scene_y)
def _scene_to_svg(self, scene_x: float, scene_y: float) -> QPointF:
"""Convert scene coordinates to SVG coordinates."""
if self.pixmap_size.width() == 0 or self.pixmap_size.height() == 0:
return QPointF(scene_x, scene_y)
scale_x = self.pixmap_size.width() / self.view_box.width()
scale_y = self.pixmap_size.height() / self.view_box.height()
svg_x = scene_x / scale_x + self.view_box.x()
svg_y = scene_y / scale_y + self.view_box.y()
return QPointF(svg_x, svg_y)
def _get_element_scene_rect(self, elem: SVGElement) -> Optional[QRectF]:
"""Get element bounding box in scene coordinates."""
svg_rect = self._get_element_svg_rect(elem)
if not svg_rect:
return None
top_left = self._svg_to_scene(svg_rect.x(), svg_rect.y())
bottom_right = self._svg_to_scene(
svg_rect.x() + svg_rect.width(),
svg_rect.y() + svg_rect.height()
)
return QRectF(top_left, bottom_right)
def _get_element_svg_rect(self, elem: SVGElement) -> Optional[QRectF]:
"""Get element bounding box in SVG coordinates."""
if elem.element_type == 'rect':
return QRectF(elem.x, elem.y, elem.width, elem.height)
elif elem.element_type == 'circle':
return QRectF(elem.cx - elem.r, elem.cy - elem.r, elem.r * 2, elem.r * 2)
elif elem.element_type == 'ellipse':
return QRectF(elem.cx - elem.rx, elem.cy - elem.ry, elem.rx * 2, elem.ry * 2)
elif elem.element_type == 'line':
x = min(elem.x1, elem.x2)
y = min(elem.y1, elem.y2)
w = abs(elem.x2 - elem.x1)
h = abs(elem.y2 - elem.y1)
return QRectF(x, y, max(w, 10), max(h, 10))
elif elem.element_type == 'text':
text_width = len(elem.text) * elem.font_size * 0.6
text_height = elem.font_size * 1.2
return QRectF(elem.x, elem.y - text_height, text_width, text_height)
return None
def _update_selection_rect(self):
"""Update the selection rectangle to match selected element."""
if not self.selected_element:
self.selection_rect.setVisible(False)
return
# Find element info
for info in self.elements:
if info.element.id == self.selected_element.id:
self.selection_rect.setRect(info.scene_rect)
self.selection_rect.update_handles()
self.selection_rect.setVisible(True)
return
self.selection_rect.setVisible(False)
def select_element(self, element: Optional[SVGElement]):
"""Select an element."""
self.selected_element = element
self._update_selection_rect()
self.element_selected.emit(element)
def select_element_by_id(self, element_id: str):
"""Select element by ID."""
for info in self.elements:
if info.element.id == element_id:
self.select_element(info.element)
return
self.select_element(None)
def clear_selection(self):
"""Clear current selection."""
self.select_element(None)
def _hit_test(self, scene_pos: QPointF) -> Optional[ElementInfo]:
"""Find element at scene position."""
# Check in reverse order (top elements first)
for info in reversed(self.elements):
if info.scene_rect.contains(scene_pos):
return info
return None
def _hit_test_handle(self, scene_pos: QPointF) -> Optional[HandlePosition]:
"""Check if position hits a resize handle."""
if not self.selection_rect.isVisible():
return None
for pos, handle in self.selection_rect.handles.items():
# Get handle rect in scene coordinates
handle_rect = handle.mapRectToScene(handle.rect())
if handle_rect.contains(scene_pos):
return pos
return None
def eventFilter(self, obj, event):
"""Handle mouse events on the viewport."""
from PyQt6.QtCore import QEvent
if obj != self.view.viewport():
return False
# Only process events when enabled
if not self._enabled:
return False
if event.type() == QEvent.Type.MouseButtonPress:
if event.button() == Qt.MouseButton.LeftButton:
return self._handle_mouse_press(event)
elif event.button() == Qt.MouseButton.RightButton:
return self._handle_context_menu(event)
elif event.type() == QEvent.Type.MouseMove:
return self._handle_mouse_move(event)
elif event.type() == QEvent.Type.MouseButtonRelease:
if event.button() == Qt.MouseButton.LeftButton:
return self._handle_mouse_release(event)
return False
def _handle_mouse_press(self, event) -> bool:
"""Handle mouse press event."""
view_pos = event.position()
scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y()))
# Check for handle hit first
handle = self._hit_test_handle(scene_pos)
if handle and self.selected_element:
self.resizing = True
self.active_handle = handle
self.drag_start_scene = scene_pos
# Store original rect
for info in self.elements:
if info.element.id == self.selected_element.id:
svg_rect = self._get_element_svg_rect(info.element)
self.element_start_rect = svg_rect
break
return True
# Check for element hit
hit = self._hit_test(scene_pos)
if hit:
self.select_element(hit.element)
self.dragging = True
self.drag_start_scene = scene_pos
self.drag_start_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y())
svg_rect = self._get_element_svg_rect(hit.element)
self.element_start_rect = svg_rect
return True
else:
self.clear_selection()
return False
def _handle_mouse_move(self, event) -> bool:
"""Handle mouse move event."""
view_pos = event.position()
scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y()))
if self.dragging and self.selected_element and self.element_start_rect:
# Calculate movement in SVG coordinates
current_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y())
delta_x = current_svg.x() - self.drag_start_svg.x()
delta_y = current_svg.y() - self.drag_start_svg.y()
new_x = self.element_start_rect.x() + delta_x
new_y = self.element_start_rect.y() + delta_y
self.element_moved.emit(self.selected_element, new_x, new_y)
return True
elif self.resizing and self.selected_element and self.element_start_rect:
# Calculate resize in SVG coordinates
current_svg = self._scene_to_svg(scene_pos.x(), scene_pos.y())
start_svg = self._scene_to_svg(
self.drag_start_scene.x(),
self.drag_start_scene.y()
)
delta_x = current_svg.x() - start_svg.x()
delta_y = current_svg.y() - start_svg.y()
x = self.element_start_rect.x()
y = self.element_start_rect.y()
w = self.element_start_rect.width()
h = self.element_start_rect.height()
handle = self.active_handle
if handle in (HandlePosition.TOP_LEFT, HandlePosition.LEFT, HandlePosition.BOTTOM_LEFT):
x += delta_x
w -= delta_x
if handle in (HandlePosition.TOP_RIGHT, HandlePosition.RIGHT, HandlePosition.BOTTOM_RIGHT):
w += delta_x
if handle in (HandlePosition.TOP_LEFT, HandlePosition.TOP, HandlePosition.TOP_RIGHT):
y += delta_y
h -= delta_y
if handle in (HandlePosition.BOTTOM_LEFT, HandlePosition.BOTTOM, HandlePosition.BOTTOM_RIGHT):
h += delta_y
# Minimum size
if w < 10:
w = 10
if h < 10:
h = 10
self.element_resized.emit(self.selected_element, x, y, w, h)
return True
else:
# Update cursor
handle = self._hit_test_handle(scene_pos)
if handle:
cursors = {
HandlePosition.TOP_LEFT: Qt.CursorShape.SizeFDiagCursor,
HandlePosition.TOP_RIGHT: Qt.CursorShape.SizeBDiagCursor,
HandlePosition.BOTTOM_LEFT: Qt.CursorShape.SizeBDiagCursor,
HandlePosition.BOTTOM_RIGHT: Qt.CursorShape.SizeFDiagCursor,
HandlePosition.TOP: Qt.CursorShape.SizeVerCursor,
HandlePosition.BOTTOM: Qt.CursorShape.SizeVerCursor,
HandlePosition.LEFT: Qt.CursorShape.SizeHorCursor,
HandlePosition.RIGHT: Qt.CursorShape.SizeHorCursor,
}
self.view.viewport().setCursor(cursors[handle])
elif self._hit_test(scene_pos):
self.view.viewport().setCursor(Qt.CursorShape.SizeAllCursor)
else:
self.view.viewport().setCursor(Qt.CursorShape.ArrowCursor)
return False
def _handle_mouse_release(self, event) -> bool:
"""Handle mouse release event."""
was_interacting = self.dragging or self.resizing
self.dragging = False
self.resizing = False
self.active_handle = None
self.drag_start_scene = None
self.drag_start_svg = None
self.element_start_rect = None
return was_interacting
def _handle_context_menu(self, event) -> bool:
"""Show context menu for element."""
view_pos = event.position()
scene_pos = self.view.mapToScene(int(view_pos.x()), int(view_pos.y()))
hit = self._hit_test(scene_pos)
if not hit:
return False
self.select_element(hit.element)
element = hit.element
# Create context menu
menu = QMenu(self.view)
menu.setStyleSheet("""
QMenu {
background-color: #2d2d2d;
color: #ffffff;
border: 1px solid #555;
}
QMenu::item {
padding: 6px 20px;
}
QMenu::item:selected {
background-color: #094771;
}
QMenu::separator {
height: 1px;
background-color: #555;
margin: 4px 8px;
}
""")
# Z-order actions
front_action = menu.addAction("Bring to Front")
forward_action = menu.addAction("Bring Forward")
backward_action = menu.addAction("Send Backward")
back_action = menu.addAction("Send to Back")
menu.addSeparator()
# Group actions
group_menu = menu.addMenu("Add to Group")
if self.groups:
for group_id in self.groups:
action = group_menu.addAction(group_id)
action.setData(("add_to_group", group_id))
else:
no_groups = group_menu.addAction("(No groups)")
no_groups.setEnabled(False)
group_menu.addSeparator()
new_group_action = group_menu.addAction("Create New Group...")
new_group_action.setData(("create_group", None))
# Check if element is in a group
remove_group_action = None
if element.parent_group:
remove_group_action = menu.addAction(f"Remove from '{element.parent_group}'")
menu.addSeparator()
# Delete action
delete_action = menu.addAction("Delete")
# Show menu
global_pos = self.view.viewport().mapToGlobal(event.position().toPoint())
action = menu.exec(global_pos)
if action == front_action:
self.bring_to_front.emit(element)
elif action == forward_action:
self.bring_forward.emit(element)
elif action == backward_action:
self.send_backward.emit(element)
elif action == back_action:
self.send_to_back.emit(element)
elif action == remove_group_action:
self.remove_from_group.emit(element)
elif action == delete_action:
self.delete_element.emit(element)
elif action and action.data():
data = action.data()
if data[0] == "add_to_group":
self.add_to_group.emit(element, data[1])
elif data[0] == "create_group":
self.create_group.emit(element)
return True
def cleanup(self):
"""Remove selection graphics from scene."""
if self.selection_rect.scene():
self.scene.removeItem(self.selection_rect)
self.view.viewport().removeEventFilter(self)

View File

@ -772,6 +772,912 @@ bracket();'''
},
}
CODE_TEMPLATES = {
'python': {
'Hello World': {
'description': 'Simple Python hello world',
'language': 'python',
'code': '''def hello_world():
"""A simple example function."""
print("Hello, World!")
if __name__ == "__main__":
hello_world()'''
},
'Class Example': {
'description': 'Python class with methods',
'language': 'python',
'code': '''class Rectangle:
"""A simple rectangle class."""
def __init__(self, width: float, height: float):
self.width = width
self.height = height
@property
def area(self) -> float:
"""Calculate the area of the rectangle."""
return self.width * self.height
@property
def perimeter(self) -> float:
"""Calculate the perimeter of the rectangle."""
return 2 * (self.width + self.height)
def __repr__(self) -> str:
return f"Rectangle({self.width}, {self.height})"
# Example usage
rect = Rectangle(10, 5)
print(f"Area: {rect.area}")
print(f"Perimeter: {rect.perimeter}")'''
},
'FastAPI Endpoint': {
'description': 'FastAPI route handler example',
'language': 'python',
'code': '''from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
app = FastAPI()
class Item(BaseModel):
id: int
name: str
description: Optional[str] = None
price: float
items_db: List[Item] = []
@app.get("/items", response_model=List[Item])
async def get_items():
"""Get all items."""
return items_db
@app.get("/items/{item_id}", response_model=Item)
async def get_item(item_id: int):
"""Get a specific item by ID."""
for item in items_db:
if item.id == item_id:
return item
raise HTTPException(status_code=404, detail="Item not found")
@app.post("/items", response_model=Item)
async def create_item(item: Item):
"""Create a new item."""
items_db.append(item)
return item'''
},
},
'javascript': {
'Hello World': {
'description': 'Simple JavaScript hello world',
'language': 'javascript',
'code': '''// Hello World in JavaScript
function greet(name) {
return `Hello, ${name}!`;
}
console.log(greet("World"));'''
},
'Async/Await Example': {
'description': 'Async function with fetch',
'language': 'javascript',
'code': '''// Async/Await API fetch example
async function fetchUserData(userId) {
try {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const userData = await response.json();
return userData;
} catch (error) {
console.error("Failed to fetch user data:", error);
throw error;
}
}
// Usage
async function main() {
const user = await fetchUserData(123);
console.log("User:", user);
}
main();'''
},
'React Component': {
'description': 'React functional component with hooks',
'language': 'jsx',
'code': '''import React, { useState, useEffect } from 'react';
function Counter({ initialValue = 0 }) {
const [count, setCount] = useState(initialValue);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return (
<div className="counter">
<h2>Count: {count}</h2>
<div className="buttons">
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
<button onClick={increment}>+</button>
</div>
</div>
);
}
export default Counter;'''
},
},
'rust': {
'Hello World': {
'description': 'Simple Rust hello world',
'language': 'rust',
'code': '''fn main() {
println!("Hello, World!");
}'''
},
'Struct Example': {
'description': 'Rust struct with implementation',
'language': 'rust',
'code': '''use std::fmt;
#[derive(Debug)]
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
fn new(width: f64, height: f64) -> Self {
Rectangle { width, height }
}
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
fn is_square(&self) -> bool {
(self.width - self.height).abs() < f64::EPSILON
}
}
impl fmt::Display for Rectangle {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Rectangle({}x{})", self.width, self.height)
}
}
fn main() {
let rect = Rectangle::new(10.0, 5.0);
println!("{}", rect);
println!("Area: {}", rect.area());
println!("Perimeter: {}", rect.perimeter());
println!("Is square: {}", rect.is_square());
}'''
},
},
'go': {
'Hello World': {
'description': 'Simple Go hello world',
'language': 'go',
'code': '''package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}'''
},
'HTTP Server': {
'description': 'Simple Go HTTP server',
'language': 'go',
'code': '''package main
import (
"encoding/json"
"log"
"net/http"
)
type Response struct {
Message string `json:"message"`
Status int `json:"status"`
}
func helloHandler(w http.ResponseWriter, r *http.Request) {
response := Response{
Message: "Hello, World!",
Status: 200,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func main() {
http.HandleFunc("/", helloHandler)
log.Println("Server starting on :8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}'''
},
},
'sql': {
'Create Table': {
'description': 'SQL table creation with constraints',
'language': 'sql',
'code': '''-- Create users table
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create orders table with foreign key
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for faster lookups
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);'''
},
'Common Queries': {
'description': 'Common SQL query patterns',
'language': 'sql',
'code': '''-- Select with join
SELECT
u.username,
u.email,
COUNT(o.id) as order_count,
COALESCE(SUM(o.total_amount), 0) as total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.username, u.email
ORDER BY total_spent DESC;
-- Subquery example
SELECT * FROM users
WHERE id IN (
SELECT DISTINCT user_id
FROM orders
WHERE total_amount > 100
);
-- Window function
SELECT
username,
total_amount,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) as order_rank
FROM users u
JOIN orders o ON u.id = o.user_id;'''
},
},
'bash': {
'Script Template': {
'description': 'Bash script with common patterns',
'language': 'bash',
'code': '''#!/bin/bash
# Script description
# Usage: ./script.sh [options]
set -euo pipefail # Exit on error, undefined vars, pipe failures
# Constants
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/tmp/script.log"
# Functions
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
die() {
log "ERROR: $*"
exit 1
}
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Options:
-h, --help Show this help message
-v, --verbose Enable verbose output
-f, --file Input file path
EOF
exit 0
}
# Parse arguments
VERBOSE=false
INPUT_FILE=""
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help) usage ;;
-v|--verbose) VERBOSE=true; shift ;;
-f|--file) INPUT_FILE="$2"; shift 2 ;;
*) die "Unknown option: $1" ;;
esac
done
# Main logic
main() {
log "Starting script..."
if [[ -n "$INPUT_FILE" ]]; then
log "Processing file: $INPUT_FILE"
fi
log "Done!"
}
main "$@"'''
},
},
'html': {
'Basic Page': {
'description': 'HTML5 page template',
'language': 'html',
'code': '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Page</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
background: #2c3e50;
color: white;
padding: 1rem 0;
}
</style>
</head>
<body>
<header>
<div class="container">
<h1>Welcome</h1>
</div>
</header>
<main class="container">
<h2>Hello, World!</h2>
<p>This is a basic HTML5 template.</p>
</main>
<script>
console.log('Page loaded!');
</script>
</body>
</html>'''
},
},
}
# SVG Templates for wireframes and diagrams
SVG_TEMPLATES = {
'wireframes': {
'Login Page': {
'description': 'Simple login form wireframe',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 500" width="400" height="500">
<rect width="100%" height="100%" fill="#f9fafb"/>
<!-- Card container -->
<rect x="50" y="80" width="300" height="340" rx="8" fill="#ffffff" stroke="#e5e7eb"/>
<!-- Logo placeholder -->
<circle cx="200" cy="130" r="30" fill="#e5e7eb"/>
<text x="200" y="135" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#9ca3af">Logo</text>
<!-- Title -->
<text x="200" y="190" text-anchor="middle" font-family="sans-serif" font-size="20" font-weight="bold" fill="#111827">Welcome Back</text>
<!-- Email input -->
<text x="70" y="230" font-family="sans-serif" font-size="12" fill="#374151">Email</text>
<rect x="70" y="240" width="260" height="40" rx="4" fill="#ffffff" stroke="#d1d5db"/>
<text x="82" y="265" font-family="sans-serif" font-size="14" fill="#9ca3af">you@example.com</text>
<!-- Password input -->
<text x="70" y="300" font-family="sans-serif" font-size="12" fill="#374151">Password</text>
<rect x="70" y="310" width="260" height="40" rx="4" fill="#ffffff" stroke="#d1d5db"/>
<text x="82" y="335" font-family="sans-serif" font-size="14" fill="#9ca3af"></text>
<!-- Sign in button -->
<rect x="70" y="370" width="260" height="40" rx="4" fill="#3b82f6"/>
<text x="200" y="395" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">Sign In</text>
<!-- Forgot password link -->
<text x="200" y="435" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#3b82f6">Forgot password?</text>
</svg>'''
},
'Dashboard Layout': {
'description': 'Dashboard with sidebar and cards',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
<rect width="100%" height="100%" fill="#f3f4f6"/>
<!-- Sidebar -->
<rect x="0" y="0" width="200" height="600" fill="#1f2937"/>
<text x="20" y="40" font-family="sans-serif" font-size="20" font-weight="bold" fill="#ffffff">Dashboard</text>
<!-- Sidebar menu items -->
<rect x="0" y="70" width="200" height="40" fill="#374151"/>
<text x="20" y="95" font-family="sans-serif" font-size="14" fill="#ffffff">Overview</text>
<text x="20" y="135" font-family="sans-serif" font-size="14" fill="#9ca3af">Analytics</text>
<text x="20" y="175" font-family="sans-serif" font-size="14" fill="#9ca3af">Reports</text>
<text x="20" y="215" font-family="sans-serif" font-size="14" fill="#9ca3af">Settings</text>
<!-- Top header -->
<rect x="200" y="0" width="600" height="60" fill="#ffffff" stroke="#e5e7eb"/>
<text x="220" y="38" font-family="sans-serif" font-size="18" fill="#111827">Welcome back, User</text>
<!-- Search box -->
<rect x="500" y="15" width="180" height="30" rx="4" fill="#f3f4f6" stroke="#d1d5db"/>
<text x="515" y="35" font-family="sans-serif" font-size="12" fill="#9ca3af">Search...</text>
<!-- Avatar -->
<circle cx="760" cy="30" r="18" fill="#e5e7eb"/>
<!-- Stats cards -->
<rect x="220" y="80" width="170" height="100" rx="8" fill="#ffffff" stroke="#e5e7eb"/>
<text x="240" y="115" font-family="sans-serif" font-size="12" fill="#6b7280">Total Users</text>
<text x="240" y="150" font-family="sans-serif" font-size="28" font-weight="bold" fill="#111827">12,345</text>
<rect x="410" y="80" width="170" height="100" rx="8" fill="#ffffff" stroke="#e5e7eb"/>
<text x="430" y="115" font-family="sans-serif" font-size="12" fill="#6b7280">Revenue</text>
<text x="430" y="150" font-family="sans-serif" font-size="28" font-weight="bold" fill="#111827">$54,321</text>
<rect x="600" y="80" width="170" height="100" rx="8" fill="#ffffff" stroke="#e5e7eb"/>
<text x="620" y="115" font-family="sans-serif" font-size="12" fill="#6b7280">Orders</text>
<text x="620" y="150" font-family="sans-serif" font-size="28" font-weight="bold" fill="#111827">1,234</text>
<!-- Main content area -->
<rect x="220" y="200" width="550" height="280" rx="8" fill="#ffffff" stroke="#e5e7eb"/>
<text x="240" y="230" font-family="sans-serif" font-size="16" font-weight="bold" fill="#111827">Recent Activity</text>
<!-- Chart placeholder -->
<rect x="240" y="250" width="510" height="200" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-dasharray="4,4"/>
<text x="495" y="355" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#9ca3af">Chart Placeholder</text>
</svg>'''
},
'Mobile App Screen': {
'description': 'Mobile app layout wireframe',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 375 812" width="375" height="812">
<!-- Phone frame -->
<rect width="100%" height="100%" fill="#ffffff" rx="40"/>
<!-- Status bar -->
<rect x="0" y="0" width="375" height="44" fill="#f9fafb"/>
<text x="187" y="28" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="bold" fill="#111827">9:41</text>
<!-- Header -->
<rect x="0" y="44" width="375" height="56" fill="#ffffff" stroke="#e5e7eb"/>
<text x="20" y="78" font-family="sans-serif" font-size="24" fill="#3b82f6"></text>
<text x="187" y="80" text-anchor="middle" font-family="sans-serif" font-size="17" font-weight="bold" fill="#111827">Profile</text>
<!-- Profile section -->
<circle cx="187" cy="170" r="50" fill="#e5e7eb"/>
<text x="187" y="175" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#9ca3af">Photo</text>
<text x="187" y="245" text-anchor="middle" font-family="sans-serif" font-size="22" font-weight="bold" fill="#111827">John Doe</text>
<text x="187" y="270" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#6b7280">john@example.com</text>
<!-- Menu items -->
<g transform="translate(0, 300)">
<rect x="20" y="0" width="335" height="56" fill="#ffffff"/>
<line x1="20" y1="56" x2="355" y2="56" stroke="#e5e7eb"/>
<text x="40" y="35" font-family="sans-serif" font-size="16" fill="#111827">Edit Profile</text>
<text x="335" y="35" font-family="sans-serif" font-size="20" fill="#9ca3af"></text>
<rect x="20" y="56" width="335" height="56" fill="#ffffff"/>
<line x1="20" y1="112" x2="355" y2="112" stroke="#e5e7eb"/>
<text x="40" y="91" font-family="sans-serif" font-size="16" fill="#111827">Notifications</text>
<text x="335" y="91" font-family="sans-serif" font-size="20" fill="#9ca3af"></text>
<rect x="20" y="112" width="335" height="56" fill="#ffffff"/>
<line x1="20" y1="168" x2="355" y2="168" stroke="#e5e7eb"/>
<text x="40" y="147" font-family="sans-serif" font-size="16" fill="#111827">Privacy</text>
<text x="335" y="147" font-family="sans-serif" font-size="20" fill="#9ca3af"></text>
<rect x="20" y="168" width="335" height="56" fill="#ffffff"/>
<text x="40" y="203" font-family="sans-serif" font-size="16" fill="#111827">Help & Support</text>
<text x="335" y="203" font-family="sans-serif" font-size="20" fill="#9ca3af"></text>
</g>
<!-- Logout button -->
<rect x="40" y="580" width="295" height="48" rx="8" fill="none" stroke="#ef4444"/>
<text x="187" y="610" text-anchor="middle" font-family="sans-serif" font-size="16" fill="#ef4444">Log Out</text>
<!-- Tab bar -->
<rect x="0" y="730" width="375" height="82" fill="#ffffff" stroke="#e5e7eb"/>
<text x="62" y="770" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#9ca3af">Home</text>
<text x="145" y="770" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#9ca3af">Search</text>
<text x="230" y="770" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#3b82f6">Profile</text>
<text x="313" y="770" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#9ca3af">Settings</text>
</svg>'''
},
},
'diagrams': {
'Simple Flowchart': {
'description': 'Basic flowchart with shapes and arrows',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" width="600" height="400">
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#374151"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="#ffffff"/>
<!-- Start (oval) -->
<ellipse cx="300" cy="40" rx="60" ry="25" fill="#10b981" stroke="#059669" stroke-width="2"/>
<text x="300" y="46" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">Start</text>
<!-- Arrow down -->
<line x1="300" y1="65" x2="300" y2="100" stroke="#374151" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Process box -->
<rect x="220" y="110" width="160" height="50" rx="4" fill="#3b82f6" stroke="#2563eb" stroke-width="2"/>
<text x="300" y="140" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">Process Data</text>
<!-- Arrow down -->
<line x1="300" y1="160" x2="300" y2="195" stroke="#374151" stroke-width="2" marker-end="url(#arrowhead)"/>
<!-- Decision diamond -->
<polygon points="300,200 380,250 300,300 220,250" fill="#fbbf24" stroke="#d97706" stroke-width="2"/>
<text x="300" y="255" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#111827">Valid?</text>
<!-- Yes path -->
<line x1="380" y1="250" x2="450" y2="250" stroke="#374151" stroke-width="2" marker-end="url(#arrowhead)"/>
<text x="410" y="240" font-family="sans-serif" font-size="12" fill="#374151">Yes</text>
<rect x="460" y="225" width="100" height="50" rx="4" fill="#10b981" stroke="#059669" stroke-width="2"/>
<text x="510" y="255" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">Save</text>
<!-- No path -->
<line x1="220" y1="250" x2="150" y2="250" stroke="#374151" stroke-width="2" marker-end="url(#arrowhead)"/>
<text x="180" y="240" font-family="sans-serif" font-size="12" fill="#374151">No</text>
<rect x="40" y="225" width="100" height="50" rx="4" fill="#ef4444" stroke="#dc2626" stroke-width="2"/>
<text x="90" y="255" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">Error</text>
<!-- End -->
<line x1="300" y1="300" x2="300" y2="340" stroke="#374151" stroke-width="2" marker-end="url(#arrowhead)"/>
<ellipse cx="300" cy="365" rx="60" ry="25" fill="#6b7280" stroke="#4b5563" stroke-width="2"/>
<text x="300" y="371" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#ffffff">End</text>
</svg>'''
},
'System Architecture': {
'description': 'Simple system architecture diagram',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">
<defs>
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="#f9fafb"/>
<!-- Title -->
<text x="400" y="30" text-anchor="middle" font-family="sans-serif" font-size="18" font-weight="bold" fill="#111827">System Architecture</text>
<!-- Client layer -->
<rect x="50" y="60" width="700" height="80" rx="8" fill="none" stroke="#d1d5db" stroke-dasharray="5,5"/>
<text x="70" y="85" font-family="sans-serif" font-size="12" fill="#6b7280">Client Layer</text>
<rect x="100" y="90" width="120" height="40" rx="4" fill="#dbeafe" stroke="#3b82f6"/>
<text x="160" y="115" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#1e40af">Web App</text>
<rect x="250" y="90" width="120" height="40" rx="4" fill="#dbeafe" stroke="#3b82f6"/>
<text x="310" y="115" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#1e40af">Mobile App</text>
<rect x="400" y="90" width="120" height="40" rx="4" fill="#dbeafe" stroke="#3b82f6"/>
<text x="460" y="115" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#1e40af">Desktop App</text>
<!-- Arrows to API Gateway -->
<line x1="160" y1="130" x2="400" y2="190" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="310" y1="130" x2="400" y2="190" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow)"/>
<line x1="460" y1="130" x2="400" y2="190" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- API Gateway -->
<rect x="300" y="190" width="200" height="50" rx="4" fill="#fef3c7" stroke="#f59e0b"/>
<text x="400" y="220" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#92400e">API Gateway</text>
<!-- Arrow to services -->
<line x1="400" y1="240" x2="400" y2="280" stroke="#6b7280" stroke-width="1.5" marker-end="url(#arrow)"/>
<!-- Services layer -->
<rect x="50" y="280" width="700" height="100" rx="8" fill="none" stroke="#d1d5db" stroke-dasharray="5,5"/>
<text x="70" y="305" font-family="sans-serif" font-size="12" fill="#6b7280">Microservices</text>
<rect x="80" y="320" width="130" height="45" rx="4" fill="#d1fae5" stroke="#10b981"/>
<text x="145" y="348" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#065f46">Auth Service</text>
<rect x="235" y="320" width="130" height="45" rx="4" fill="#d1fae5" stroke="#10b981"/>
<text x="300" y="348" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#065f46">User Service</text>
<rect x="390" y="320" width="130" height="45" rx="4" fill="#d1fae5" stroke="#10b981"/>
<text x="455" y="348" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#065f46">Order Service</text>
<rect x="545" y="320" width="130" height="45" rx="4" fill="#d1fae5" stroke="#10b981"/>
<text x="610" y="348" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#065f46">Payment Service</text>
<!-- Data layer -->
<rect x="50" y="410" width="700" height="70" rx="8" fill="none" stroke="#d1d5db" stroke-dasharray="5,5"/>
<text x="70" y="435" font-family="sans-serif" font-size="12" fill="#6b7280">Data Layer</text>
<!-- Databases -->
<ellipse cx="200" cy="455" rx="60" ry="20" fill="#fce7f3" stroke="#ec4899"/>
<text x="200" y="460" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#9d174d">PostgreSQL</text>
<ellipse cx="400" cy="455" rx="60" ry="20" fill="#fce7f3" stroke="#ec4899"/>
<text x="400" y="460" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#9d174d">Redis Cache</text>
<ellipse cx="600" cy="455" rx="60" ry="20" fill="#fce7f3" stroke="#ec4899"/>
<text x="600" y="460" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#9d174d">MongoDB</text>
<!-- Arrows from services to databases -->
<line x1="145" y1="365" x2="190" y2="435" stroke="#6b7280" stroke-width="1" marker-end="url(#arrow)"/>
<line x1="300" y1="365" x2="395" y2="435" stroke="#6b7280" stroke-width="1" marker-end="url(#arrow)"/>
<line x1="610" y1="365" x2="600" y2="435" stroke="#6b7280" stroke-width="1" marker-end="url(#arrow)"/>
</svg>'''
},
},
'basic': {
'Shapes Gallery': {
'description': 'Common SVG shapes reference',
'code': '''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 400" width="600" height="400">
<rect width="100%" height="100%" fill="#f9fafb"/>
<text x="300" y="30" text-anchor="middle" font-family="sans-serif" font-size="18" font-weight="bold" fill="#111827">SVG Shapes Reference</text>
<!-- Rectangle -->
<rect x="50" y="60" width="120" height="80" rx="8" fill="#3b82f6" stroke="#1d4ed8" stroke-width="2"/>
<text x="110" y="170" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Rectangle</text>
<!-- Circle -->
<circle cx="280" cy="100" r="45" fill="#10b981" stroke="#059669" stroke-width="2"/>
<text x="280" y="170" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Circle</text>
<!-- Ellipse -->
<ellipse cx="450" cy="100" rx="70" ry="40" fill="#f59e0b" stroke="#d97706" stroke-width="2"/>
<text x="450" y="170" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Ellipse</text>
<!-- Line -->
<line x1="50" y1="220" x2="170" y2="280" stroke="#6366f1" stroke-width="3"/>
<text x="110" y="310" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Line</text>
<!-- Polygon (triangle) -->
<polygon points="280,200 330,280 230,280" fill="#ec4899" stroke="#be185d" stroke-width="2"/>
<text x="280" y="310" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Polygon</text>
<!-- Path (curve) -->
<path d="M 380 280 Q 450 180 520 280" fill="none" stroke="#8b5cf6" stroke-width="3"/>
<text x="450" y="310" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Path (Curve)</text>
<!-- Text -->
<text x="110" y="370" text-anchor="middle" font-family="sans-serif" font-size="24" font-weight="bold" fill="#111827">Text</text>
<text x="110" y="390" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Text Element</text>
<!-- Polyline -->
<polyline points="230,330 260,380 290,340 320,370" fill="none" stroke="#14b8a6" stroke-width="3"/>
<text x="280" y="390" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Polyline</text>
<!-- Group with transform -->
<g transform="translate(450, 350)">
<rect x="-30" y="-20" width="60" height="40" fill="#f43f5e"/>
<circle cx="0" cy="0" r="15" fill="#fef2f2"/>
</g>
<text x="450" y="390" text-anchor="middle" font-family="sans-serif" font-size="12" fill="#374151">Group</text>
</svg>'''
},
},
}
# Excalidraw Templates (JSON format)
EXCALIDRAW_TEMPLATES = {
'diagrams': {
'Simple Flowchart': {
'description': 'Hand-drawn flowchart with shapes and arrows',
'code': '''{
"type": "excalidraw",
"version": 2,
"elements": [
{"type": "rectangle", "id": "start", "x": 200, "y": 20, "width": 120, "height": 50, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "start-txt", "x": 230, "y": 35, "width": 60, "height": 25, "text": "Start", "fontSize": 20, "strokeColor": "#087f5b"},
{"type": "arrow", "id": "a1", "x": 260, "y": 70, "width": 0, "height": 40, "points": [[0, 0], [0, 40]], "strokeColor": "#495057"},
{"type": "rectangle", "id": "proc1", "x": 180, "y": 120, "width": 160, "height": 60, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "proc1-txt", "x": 200, "y": 140, "width": 120, "height": 25, "text": "Process", "fontSize": 20, "strokeColor": "#1864ab"},
{"type": "arrow", "id": "a2", "x": 260, "y": 180, "width": 0, "height": 40, "points": [[0, 0], [0, 40]], "strokeColor": "#495057"},
{"type": "diamond", "id": "decision", "x": 185, "y": 230, "width": 150, "height": 100, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "dec-txt", "x": 225, "y": 270, "width": 70, "height": 25, "text": "Valid?", "fontSize": 18, "strokeColor": "#e67700"},
{"type": "arrow", "id": "a3-yes", "x": 335, "y": 280, "width": 80, "height": 0, "points": [[0, 0], [80, 0]], "strokeColor": "#2b8a3e"},
{"type": "text", "id": "yes-lbl", "x": 355, "y": 260, "width": 40, "height": 20, "text": "Yes", "fontSize": 14, "strokeColor": "#2b8a3e"},
{"type": "rectangle", "id": "success", "x": 420, "y": 255, "width": 100, "height": 50, "strokeColor": "#2b8a3e", "backgroundColor": "#d3f9d8", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "succ-txt", "x": 445, "y": 270, "width": 50, "height": 25, "text": "Done", "fontSize": 18, "strokeColor": "#2b8a3e"},
{"type": "arrow", "id": "a3-no", "x": 185, "y": 280, "width": -80, "height": 0, "points": [[0, 0], [-80, 0]], "strokeColor": "#c92a2a"},
{"type": "text", "id": "no-lbl", "x": 130, "y": 260, "width": 40, "height": 20, "text": "No", "fontSize": 14, "strokeColor": "#c92a2a"},
{"type": "rectangle", "id": "error", "x": 0, "y": 255, "width": 100, "height": 50, "strokeColor": "#c92a2a", "backgroundColor": "#ffe3e3", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "err-txt", "x": 25, "y": 270, "width": 50, "height": 25, "text": "Error", "fontSize": 18, "strokeColor": "#c92a2a"}
],
"appState": {"viewBackgroundColor": "#ffffff"}
}'''
},
'Mind Map': {
'description': 'Simple mind map with central topic',
'code': '''{
"type": "excalidraw",
"version": 2,
"elements": [
{"type": "ellipse", "id": "center", "x": 300, "y": 200, "width": 160, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1, "strokeWidth": 2},
{"type": "text", "id": "center-txt", "x": 335, "y": 230, "width": 90, "height": 30, "text": "Main Topic", "fontSize": 20, "strokeColor": "#1864ab"},
{"type": "line", "id": "l1", "x": 380, "y": 200, "width": 100, "height": -80, "points": [[0, 0], [100, -80]], "strokeColor": "#495057"},
{"type": "rectangle", "id": "t1", "x": 480, "y": 80, "width": 120, "height": 50, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "t1-txt", "x": 505, "y": 95, "width": 70, "height": 25, "text": "Topic 1", "fontSize": 16, "strokeColor": "#087f5b"},
{"type": "line", "id": "l2", "x": 460, "y": 240, "width": 80, "height": 0, "points": [[0, 0], [80, 0]], "strokeColor": "#495057"},
{"type": "rectangle", "id": "t2", "x": 540, "y": 215, "width": 120, "height": 50, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "t2-txt", "x": 565, "y": 230, "width": 70, "height": 25, "text": "Topic 2", "fontSize": 16, "strokeColor": "#e67700"},
{"type": "line", "id": "l3", "x": 380, "y": 280, "width": 100, "height": 80, "points": [[0, 0], [100, 80]], "strokeColor": "#495057"},
{"type": "rectangle", "id": "t3", "x": 480, "y": 350, "width": 120, "height": 50, "strokeColor": "#862e9c", "backgroundColor": "#f3d9fa", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "t3-txt", "x": 505, "y": 365, "width": 70, "height": 25, "text": "Topic 3", "fontSize": 16, "strokeColor": "#862e9c"},
{"type": "line", "id": "l4", "x": 300, "y": 240, "width": -80, "height": 60, "points": [[0, 0], [-80, 60]], "strokeColor": "#495057"},
{"type": "rectangle", "id": "t4", "x": 100, "y": 290, "width": 120, "height": 50, "strokeColor": "#c92a2a", "backgroundColor": "#ffe3e3", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "t4-txt", "x": 125, "y": 305, "width": 70, "height": 25, "text": "Topic 4", "fontSize": 16, "strokeColor": "#c92a2a"}
],
"appState": {"viewBackgroundColor": "#ffffff"}
}'''
},
},
'wireframes': {
'Login Sketch': {
'description': 'Hand-drawn login form sketch',
'code': '''{
"type": "excalidraw",
"version": 2,
"elements": [
{"type": "rectangle", "id": "card", "x": 100, "y": 50, "width": 280, "height": 350, "strokeColor": "#495057", "backgroundColor": "#f8f9fa", "fillStyle": "solid", "roughness": 1, "strokeWidth": 2},
{"type": "ellipse", "id": "logo", "x": 200, "y": 80, "width": 80, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "logo-txt", "x": 220, "y": 110, "width": 40, "height": 25, "text": "Logo", "fontSize": 14, "strokeColor": "#1864ab"},
{"type": "text", "id": "title", "x": 175, "y": 180, "width": 130, "height": 30, "text": "Welcome Back", "fontSize": 20, "strokeColor": "#212529"},
{"type": "rectangle", "id": "email", "x": 130, "y": 220, "width": 220, "height": 40, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "email-txt", "x": 145, "y": 233, "width": 50, "height": 20, "text": "Email...", "fontSize": 14, "strokeColor": "#adb5bd"},
{"type": "rectangle", "id": "pass", "x": 130, "y": 275, "width": 220, "height": 40, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "pass-txt", "x": 145, "y": 288, "width": 70, "height": 20, "text": "Password...", "fontSize": 14, "strokeColor": "#adb5bd"},
{"type": "rectangle", "id": "btn", "x": 130, "y": 335, "width": 220, "height": 45, "strokeColor": "#1864ab", "backgroundColor": "#339af0", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "btn-txt", "x": 200, "y": 350, "width": 80, "height": 20, "text": "Sign In", "fontSize": 16, "strokeColor": "#ffffff"}
],
"appState": {"viewBackgroundColor": "#ffffff"}
}'''
},
'Mobile Screen': {
'description': 'Hand-drawn mobile app screen',
'code': '''{
"type": "excalidraw",
"version": 2,
"elements": [
{"type": "rectangle", "id": "phone", "x": 100, "y": 20, "width": 240, "height": 480, "strokeColor": "#212529", "backgroundColor": "#f8f9fa", "fillStyle": "solid", "roughness": 1, "strokeWidth": 3},
{"type": "rectangle", "id": "statusbar", "x": 100, "y": 20, "width": 240, "height": 30, "strokeColor": "#212529", "backgroundColor": "#e9ecef", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "time", "x": 195, "y": 30, "width": 50, "height": 20, "text": "9:41", "fontSize": 14, "strokeColor": "#212529"},
{"type": "rectangle", "id": "header", "x": 100, "y": 50, "width": 240, "height": 50, "strokeColor": "#1864ab", "backgroundColor": "#339af0", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "header-txt", "x": 180, "y": 68, "width": 80, "height": 20, "text": "My App", "fontSize": 18, "strokeColor": "#ffffff"},
{"type": "rectangle", "id": "card1", "x": 115, "y": 115, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "card1-txt", "x": 130, "y": 140, "width": 100, "height": 20, "text": "Item 1", "fontSize": 16, "strokeColor": "#212529"},
{"type": "rectangle", "id": "card2", "x": 115, "y": 210, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "card2-txt", "x": 130, "y": 235, "width": 100, "height": 20, "text": "Item 2", "fontSize": 16, "strokeColor": "#212529"},
{"type": "rectangle", "id": "card3", "x": 115, "y": 305, "width": 210, "height": 80, "strokeColor": "#adb5bd", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "card3-txt", "x": 130, "y": 330, "width": 100, "height": 20, "text": "Item 3", "fontSize": 16, "strokeColor": "#212529"},
{"type": "rectangle", "id": "tabbar", "x": 100, "y": 450, "width": 240, "height": 50, "strokeColor": "#212529", "backgroundColor": "#ffffff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "tab1", "x": 130, "y": 468, "width": 40, "height": 15, "text": "Home", "fontSize": 12, "strokeColor": "#1864ab"},
{"type": "text", "id": "tab2", "x": 200, "y": 468, "width": 40, "height": 15, "text": "Search", "fontSize": 12, "strokeColor": "#868e96"},
{"type": "text", "id": "tab3", "x": 275, "y": 468, "width": 40, "height": 15, "text": "Profile", "fontSize": 12, "strokeColor": "#868e96"}
],
"appState": {"viewBackgroundColor": "#ffffff"}
}'''
},
},
'basic': {
'Shapes Demo': {
'description': 'Basic shapes in Excalidraw style',
'code': '''{
"type": "excalidraw",
"version": 2,
"elements": [
{"type": "rectangle", "id": "rect", "x": 50, "y": 50, "width": 120, "height": 80, "strokeColor": "#1864ab", "backgroundColor": "#d0ebff", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "rect-lbl", "x": 75, "y": 150, "width": 70, "height": 20, "text": "Rectangle", "fontSize": 12, "strokeColor": "#495057"},
{"type": "ellipse", "id": "ellipse", "x": 220, "y": 50, "width": 100, "height": 80, "strokeColor": "#087f5b", "backgroundColor": "#c3fae8", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "ell-lbl", "x": 245, "y": 150, "width": 50, "height": 20, "text": "Ellipse", "fontSize": 12, "strokeColor": "#495057"},
{"type": "diamond", "id": "diamond", "x": 370, "y": 40, "width": 100, "height": 100, "strokeColor": "#e67700", "backgroundColor": "#fff3bf", "fillStyle": "solid", "roughness": 1},
{"type": "text", "id": "dia-lbl", "x": 390, "y": 150, "width": 60, "height": 20, "text": "Diamond", "fontSize": 12, "strokeColor": "#495057"},
{"type": "line", "id": "line", "x": 50, "y": 200, "width": 100, "height": 60, "points": [[0, 0], [100, 60]], "strokeColor": "#862e9c", "strokeWidth": 2, "roughness": 1},
{"type": "text", "id": "line-lbl", "x": 75, "y": 280, "width": 50, "height": 20, "text": "Line", "fontSize": 12, "strokeColor": "#495057"},
{"type": "arrow", "id": "arrow", "x": 220, "y": 200, "width": 100, "height": 60, "points": [[0, 0], [100, 60]], "strokeColor": "#c92a2a", "strokeWidth": 2, "roughness": 1},
{"type": "text", "id": "arr-lbl", "x": 250, "y": 280, "width": 50, "height": 20, "text": "Arrow", "fontSize": 12, "strokeColor": "#495057"},
{"type": "text", "id": "text-demo", "x": 370, "y": 220, "width": 100, "height": 40, "text": "Text\\nElement", "fontSize": 20, "strokeColor": "#212529"},
{"type": "text", "id": "txt-lbl", "x": 395, "y": 280, "width": 50, "height": 20, "text": "Text", "fontSize": 12, "strokeColor": "#495057"}
],
"appState": {"viewBackgroundColor": "#ffffff"}
}'''
},
},
}
# Combined templates dict for backwards compatibility
TEMPLATES = PLANTUML_TEMPLATES
@ -788,6 +1694,9 @@ def get_templates_for_format(format_type: str) -> dict:
'plantuml': PLANTUML_TEMPLATES,
'mermaid': MERMAID_TEMPLATES,
'openscad': OPENSCAD_TEMPLATES,
'code': CODE_TEMPLATES,
'svg': SVG_TEMPLATES,
'excalidraw': EXCALIDRAW_TEMPLATES,
}
return format_map.get(format_type, PLANTUML_TEMPLATES)