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:
parent
6aa226b136
commit
d20d7e5415
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
56
README.md
56
README.md
|
|
@ -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:
|
||||
|
|
|
|||
28
install.sh
28
install.sh
|
|
@ -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\""
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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
|
|
@ -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'),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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('&', '&').replace('<', '<').replace('>', '>')
|
||||
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, ""
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue