
Bmad Excalidraw
- 6 installs
- 186 repo stars
- Updated June 22, 2026
- bmad-code-org/bmad-builder
bmad-excalidraw is a Claude skill that generates Excalidraw diagram files (flowcharts, architecture diagrams, mind maps and more) from a text description or a guided design conversation.
About
bmad-excalidraw generates Excalidraw diagram files from a description or through a guided design conversation. A developer uses it to produce flowcharts, architecture diagrams, sequence flows, mind maps and other visuals as ready-to-open .excalidraw JSON. It offers guided, YOLO, and autonomous (--headless) modes so it fits both first-time users and non-interactive pipelines.
- Turns a rough idea into a ready-to-open .excalidraw file via conversational or headless generation
- Supports flowcharts, architecture, sequence, mind map, ER, swimlane, data-flow and more
- Ships generate_excalidraw.py and validate_excalidraw.py for auto-layout and structure checks
Bmad Excalidraw by the numbers
- 6 all-time installs (skills.sh)
- Ranked #1,506 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
bmad-excalidraw capabilities & compatibility
- Capabilities
- diagram generation · flowchart builder · architecture diagram
- Use cases
- ui design · documentation
What bmad-excalidraw says it does
Produce professional diagrams and visual aids as Excalidraw files through conversational design or autonomous generation.
Your output is a ready-to-open Excalidraw diagram — flowcharts, architecture diagrams, sequence flows, mind maps, and more.
npx skills add https://github.com/bmad-code-org/bmad-builder --skill bmad-excalidrawAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| repo stars | ★ 186 |
| Last updated | June 22, 2026 |
| Repository | bmad-code-org/bmad-builder ↗ |
What it does
Create Excalidraw flowcharts, architecture diagrams, and other visual aids from a text description or guided design session.
Who is it for?
Producing Excalidraw diagrams (flowcharts, architecture, sequence, mind maps) from a description or guided session
Skip if: Rendering diagrams in other formats such as Mermaid, PlantUML, or image files
When should I use this skill?
The user asks to create a diagram, make an Excalidraw, draw a flowchart, or visualize an architecture
What you get
A polished, ready-to-open .excalidraw file saved to the output folder.
- A ready-to-open .excalidraw file
By the numbers
- 10-type diagram catalog (flowchart through comparison/matrix)
- 3 activation modes (guided, YOLO, autonomous)
- 2 bundled Python scripts (generate + validate)
Files
Excalidraw Diagram Builder
Overview
Produce professional diagrams and visual aids as Excalidraw files through conversational design or autonomous generation. Act as a visual design consultant and diagramming expert, guiding users from a rough idea to a polished .excalidraw file. Your output is a ready-to-open Excalidraw diagram — flowcharts, architecture diagrams, sequence flows, mind maps, and more.
Domain context: Excalidraw is a virtual whiteboard tool that produces hand-drawn-style diagrams. Files are JSON with a well-defined element schema (rectangles, ellipses, diamonds, arrows, lines, text, frames). Users may not know what diagram type best fits their need — part of your job is helping them figure that out.
Design rationale: Three modes exist because users have different contexts: first-timers need guided discovery, repeat users with clear inputs want fast output, and pipelines want zero interaction.
Activation Mode Detection
Check activation context immediately:
1. Autonomous mode: If the user passes --headless or -H flags, or if their intent clearly indicates non-interactive execution:
- Skip questions, infer diagram type and content from the prompt
- Generate the diagram with sensible defaults
- Save to
{output_folder}/diagrams/and report the path - If
--headless:{diagram-type}or-H:{diagram-type}→ use that specific diagram type
2. YOLO mode: If the user says --yolo or "just make it" or provides a very specific complete description:
- Infer everything possible from the input
- Generate the diagram immediately
- Offer one quick "Want me to adjust anything?" before finishing
3. Guided mode (default): Proceed to full interactive flow below
On Activation
1. Load config from {project-root}/_bmad/config.yaml and config.user.yaml. If missing, continue with fallbacks:
{user_name}— fallback: omit{communication_language}— fallback: match the user's language{output_folder}— fallback:{project-root}/diagrams
2. Greet user as {user_name}, speaking in {communication_language}
3. Detect diagram intent from user's request:
- What do they want to visualize?
- Did they specify a diagram type? If so, validate against
./references/diagram-types.md - Did they specify enough detail to skip guided design?
4. Route by mode:
- Autonomous/YOLO →
./references/diagram-generation.mddirectly - Guided →
./references/guided-design.mdfirst, then./references/diagram-generation.md
Stages
| # | Stage | Purpose | Prompt |
|---|---|---|---|
| 1 | Guided Design | Creative facilitation — brainstorm diagram type, content, layout | ./references/guided-design.md |
| 2 | Generation | Produce the .excalidraw file with proper layout | ./references/diagram-generation.md |
Headless: skip guided-design, output file path on completion.
Scripts
Available scripts in scripts/:
generate_excalidraw.py— Takes a diagram specification JSON and produces a valid.excalidrawfile with auto-layoutvalidate_excalidraw.py— Validates.excalidrawfile structure and reports issues
Language: Use {communication_language} for all output. Output Location: {output_folder}/diagrams/
Diagram Generation
This stage receives either a confirmed specification from the guided-design stage or an inferred description from the user's autonomous/YOLO request. If a complete spec was confirmed in a prior stage, skip directly to Step 2. If inferring from user input, build the spec in Step 1 from the conversation context.
Generate a valid .excalidraw file from the diagram specification. Use the schema reference and generation script to produce a well-laid-out, visually clean diagram.
Step 1: Build the Diagram Specification
Create a JSON specification that the generation script can consume. Load ./excalidraw-schema.md for the element format reference.
The specification format:
{
"title": "Diagram Title",
"type": "flowchart|architecture|sequence|mindmap|er|swimlane|dataflow|wireframe|network|comparison|freeform",
"direction": "LR|TB|RL|BT",
"elements": [
{
"id": "unique-id",
"type": "rectangle|diamond|ellipse|text",
"label": "Display Text",
"group": "optional-group-name"
}
],
"connections": [
{
"from": "source-id",
"to": "target-id",
"label": "optional label",
"style": "arrow|line|dashed"
}
],
"groups": [
{
"name": "group-name",
"label": "Group Label",
"type": "frame|background"
}
]
}Step 2: Generate the Excalidraw File
Run the generation script:
python3 ../scripts/generate_excalidraw.py --spec '<json-spec>' --output '{output_folder}/diagrams/{filename}.excalidraw'Or pipe the spec via stdin:
echo '<json-spec>' | python3 ../scripts/generate_excalidraw.py --output '{output_folder}/diagrams/{filename}.excalidraw'The script handles:
- Auto-layout based on diagram type and direction
- Element sizing based on text content
- Arrow routing between elements
- Proper spacing and alignment
- Hand-drawn style defaults (roughness, rounded corners)
- Unique ID generation for all elements
Step 3: Validate
Run validation:
python3 ../scripts/validate_excalidraw.py '{output_folder}/diagrams/{filename}.excalidraw'Fix any critical issues before delivering.
Step 4: Deliver
Present the result:
1. Confirm the file was saved: "Diagram saved to {output_folder}/diagrams/{filename}.excalidraw" 2. Summarize what was created: element count, diagram type, key components 3. Explain how to open it: "Open in Excalidraw (excalidraw.com) or any compatible editor"
For Guided/YOLO mode: Ask "Want me to adjust anything — add elements, change layout, restyle?"
For Autonomous mode: Just output the file path and a one-line summary.
Iteration
If the user wants changes:
- Read the existing file
- Apply modifications to the spec
- Re-run generation
- Re-validate and deliver
Progression
Guided/YOLO mode: When the user confirms no further changes or declines the adjustment offer, this stage is complete. Confirm the final file path and summarize the diagram.
Autonomous mode: Stage completes immediately after the deliver step — output file path and one-line summary, then done. No iteration.
Supported Diagram Types
Reference for diagram type selection and element mapping. Use this to suggest the best diagram type for the user's needs.
Type Catalog
Flowchart
Best for: Sequential processes, decision trees, approval workflows, algorithms Elements: Rectangles (steps), diamonds (decisions), ellipses (start/end), arrows (flow) Direction: Usually LR (left-to-right) or TB (top-to-bottom) Signals: User says "process", "steps", "decision", "if/then", "workflow", "flow"
Architecture Diagram
Best for: System components, service relationships, infrastructure, tech stack Elements: Rectangles (services/components), frames (boundaries/groups), arrows (data flow) Direction: Usually TB or freeform Signals: User says "system", "architecture", "components", "services", "infrastructure", "how things connect"
Sequence Diagram
Best for: API calls, message passing, request/response flows, protocol interactions Elements: Rectangles (participants), arrows (messages), text (labels) Direction: TB (time flows down) Signals: User says "sequence", "messages", "API flow", "request/response", "who talks to whom"
Mind Map
Best for: Brainstorming, concept exploration, hierarchical categorization, knowledge organization Elements: Ellipses/rectangles (nodes), lines (branches) Direction: Radial from center Signals: User says "brainstorm", "ideas", "categories", "mind map", "organize thoughts"
Entity Relationship (ER) Diagram
Best for: Database schema, data models, entity relationships Elements: Rectangles (entities), diamonds (relationships), text (attributes), arrows (connections) Direction: Freeform or TB Signals: User says "database", "schema", "entities", "relationships", "data model", "tables"
Swimlane Diagram
Best for: Cross-functional processes, responsibility mapping, handoff visualization Elements: Frames (lanes), rectangles (tasks), arrows (flow), text (labels) Direction: LR with vertical lanes, or TB with horizontal lanes Signals: User says "who does what", "responsibilities", "teams", "handoffs", "cross-functional"
Data Flow Diagram
Best for: Data transformation pipelines, ETL processes, input/output mapping Elements: Ellipses (processes), rectangles (data stores), arrows (data flow), text (labels) Direction: LR or TB Signals: User says "data flow", "pipeline", "transforms", "inputs/outputs", "ETL"
Wireframe / Mockup
Best for: UI layout sketches, screen designs, page structure Elements: Rectangles (containers/elements), text (labels/content), lines (dividers) Direction: TB (page flow) Signals: User says "wireframe", "mockup", "UI", "screen", "layout", "page design"
Network / Topology Diagram
Best for: Network infrastructure, node relationships, cluster layouts Elements: Ellipses/rectangles (nodes), lines/arrows (connections), frames (zones) Direction: Freeform Signals: User says "network", "topology", "nodes", "connections", "cluster"
Comparison / Matrix
Best for: Feature comparisons, pros/cons, decision matrices Elements: Rectangles (cells), text (labels/values), lines (grid) Direction: Grid layout Signals: User says "compare", "matrix", "pros/cons", "versus", "trade-offs"
Selection Heuristic
1. Look for signal words in the user's description 2. If multiple types could work, suggest the top 2 with trade-offs 3. When in doubt, flowchart is the safest default for process-oriented requests 4. Architecture diagram is the safest default for system-oriented requests 5. Let the user override — they know their audience
Excalidraw JSON Schema Reference
Reference for generating valid .excalidraw files. Use this when constructing diagram specifications or when direct JSON generation is needed.
Top-Level Structure
{
"type": "excalidraw",
"version": 2,
"source": "bmad-excalidraw",
"elements": [],
"appState": {
"gridSize": null,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}Element Types
Nine types: rectangle, ellipse, diamond, arrow, line, freedraw, text, image, frame
Common Element Properties
Every element shares these base properties:
| Property | Type | Description |
|---|---|---|
id | string | Unique identifier (use nanoid-style, 8+ chars) |
type | string | One of the nine element types |
x | number | X position (pixels from origin) |
y | number | Y position (pixels from origin) |
width | number | Element width in pixels |
height | number | Element height in pixels |
angle | number | Rotation in radians (0 = no rotation) |
strokeColor | string | Border/stroke color (hex, e.g. "#1e1e1e") |
backgroundColor | string | Fill color ("transparent" or hex) |
fillStyle | string | "solid", "hachure", "cross-hatch" |
strokeWidth | number | Line thickness (1, 2, or 4) |
strokeStyle | string | "solid", "dashed", "dotted" |
roughness | number | 0 (architect/sharp), 1 (artist/default), 2 (cartoonist) |
opacity | number | 0-100 (100 = fully opaque) |
groupIds | array | Group membership ["group-id"] |
frameId | string/null | Containing frame ID |
roundness | object/null | {"type": 3} for rounded corners, null for sharp |
seed | number | Random seed for hand-drawn rendering |
version | number | Element version counter (start at 1) |
versionNonce | number | Random nonce for version (any integer) |
isDeleted | boolean | Soft-delete flag (always false for new elements) |
boundElements | array/null | Elements bound to this one |
updated | number | Timestamp in milliseconds |
link | string/null | Optional hyperlink |
locked | boolean | Whether element is locked |
Shape Elements (rectangle, ellipse, diamond)
Use common properties only. For bound text, add to boundElements:
{
"id": "rect1",
"type": "rectangle",
"x": 100,
"y": 100,
"width": 200,
"height": 80,
"strokeColor": "#1e1e1e",
"backgroundColor": "#a5d8ff",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"roundness": { "type": 3 },
"seed": 12345,
"version": 1,
"versionNonce": 67890,
"isDeleted": false,
"boundElements": [
{ "id": "text1", "type": "text" },
{ "id": "arrow1", "type": "arrow" }
],
"updated": 1700000000000,
"link": null,
"locked": false
}Text Elements
Additional properties for text:
| Property | Type | Description |
|---|---|---|
text | string | The displayed text |
fontSize | number | Font size (16, 20, 28, 36) |
fontFamily | number | 1 (Virgil/hand-drawn), 2 (Helvetica), 3 (Cascadia/mono) |
textAlign | string | "left", "center", "right" |
verticalAlign | string | "top", "middle" |
containerId | string/null | Parent shape ID (when text is inside a shape) |
originalText | string | Same as text |
autoResize | boolean | Whether text auto-resizes (true) |
lineHeight | number | Line height multiplier (1.25 default) |
Bound text (text inside a shape):
{
"id": "text1",
"type": "text",
"x": 120,
"y": 125,
"width": 160,
"height": 25,
"text": "Process Step",
"fontSize": 20,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "rect1",
"originalText": "Process Step",
"autoResize": true,
"lineHeight": 1.25
}Linear Elements (arrow, line)
Additional properties:
| Property | Type | Description |
|---|---|---|
points | array | Array of [x, y] offsets from element origin |
startBinding | object/null | Connection to start element |
endBinding | object/null | Connection to end element |
startArrowhead | string/null | null, "arrow", "bar", "dot", "triangle" |
endArrowhead | string/null | Same options as startArrowhead |
lastCommittedPoint | null | Always null |
Arrow connecting two shapes:
{
"id": "arrow1",
"type": "arrow",
"x": 300,
"y": 140,
"width": 100,
"height": 0,
"points": [
[0, 0],
[100, 0]
],
"startBinding": {
"elementId": "rect1",
"focus": 0,
"gap": 1,
"fixedPoint": [1, 0.5]
},
"endBinding": {
"elementId": "rect2",
"focus": 0,
"gap": 1,
"fixedPoint": [0, 0.5]
},
"startArrowhead": null,
"endArrowhead": "arrow",
"lastCommittedPoint": null
}Binding fixedPoint values:
[0, 0.5]= left center[1, 0.5]= right center[0.5, 0]= top center[0.5, 1]= bottom center
Frame Elements
Frames act as containers. Other elements reference frames via frameId.
{
"id": "frame1",
"type": "frame",
"x": 50,
"y": 50,
"width": 500,
"height": 400,
"name": "Backend Services"
}Color Palette
Recommended stroke colors
"#1e1e1e"— Black (default)"#e03131"— Red"#2f9e44"— Green"#1971c2"— Blue"#f08c00"— Orange"#6741d9"— Purple
Recommended background colors
"transparent"— No fill"#a5d8ff"— Light blue"#b2f2bb"— Light green"#ffc9c9"— Light red/pink"#ffec99"— Light yellow"#d0bfff"— Light purple"#e9ecef"— Light gray
Layout Constants
Use these for consistent spacing:
| Constant | Value | Use |
|---|---|---|
| Element width (standard) | 200 | Rectangles, diamonds |
| Element height (standard) | 80 | Rectangles |
| Diamond size | 140 x 100 | Decision diamonds |
| Ellipse size | 160 x 60 | Start/end terminals |
| Horizontal gap | 80 | Between elements (LR flow) |
| Vertical gap | 60 | Between elements (TB flow) |
| Text padding | 20 | Inside shapes |
| Frame padding | 40 | Inside frames |
| Font size (label) | 20 | Standard labels |
| Font size (title) | 28 | Titles/headers |
| Font size (small) | 16 | Annotations |
Language: Use {communication_language} for all output.
Guided Diagram Design
You are a visual design consultant helping the user create the perfect diagram. Your goal is to understand what they need to communicate and translate that into a concrete diagram specification.
Step 1: Understand the Subject
If the user hasn't already explained, ask:
- What concept, system, or process are they trying to visualize?
- Who is the audience? (technical team, stakeholders, documentation, personal notes)
- What's the key insight or relationship they want to highlight?
Capture any details they've already provided — don't re-ask what they've told you. As the user describes their system, silently capture any mentioned components, relationships, or constraints even if out of sequence. Maintain a running internal context log and surface captured items at the spec confirmation step.
Step 2: Suggest Diagram Type
Load ./diagram-types.md for the full catalog.
Based on what you know, suggest the best-fit diagram type(s) with reasoning:
Example:
Based on what you're describing — a multi-step approval process with decision points — I'd recommend a Flowchart. It handles sequential steps, branching decisions, and parallel paths well.
>
Alternatively, a Swimlane Diagram could work if you want to show which team/role owns each step.
>
Which feels right, or would you like to explore other options?
If they specified a type, validate it's a good fit and confirm or suggest alternatives.
Step 3: Map the Content
Work conversationally to identify:
For flowcharts/process diagrams:
- Start/end points
- Key steps (what are the main boxes?)
- Decision points (where does it branch?)
- Connections and flow direction
For architecture/system diagrams:
- Components/services (what are the boxes?)
- Relationships between them (what connects to what?)
- Data flow direction
- External systems or boundaries
For mind maps/concept diagrams:
- Central concept
- Main branches (categories)
- Sub-branches (details)
- Cross-connections if any
For sequence diagrams:
- Participants/actors
- Message flow (who sends what to whom?)
- Response patterns
- Alt/optional flows
Use soft gates: present what you've captured, then "Anything else, or shall we build this?"
Step 4: Confirm the Specification
Present a clear summary of what you'll build:
**Diagram Type:** Flowchart
**Elements:**
- Start: "User submits form"
- Process: "Validate input" → "Check permissions"
- Decision: "Has access?" → Yes: "Process request" / No: "Show error"
- End: "Return response"
**Style:** Default hand-drawn, left-to-right flowAsk: "Ready to generate, or want to adjust anything?"
Progression
When the user confirms → proceed to ./diagram-generation.md with the complete specification.
# /// script
# requires-python = ">=3.9"
# ///
"""
Excalidraw Diagram Generator
Takes a diagram specification JSON and produces a valid .excalidraw file
with auto-layout positioning.
Usage:
python generate_excalidraw.py --spec '{"title":"My Diagram",...}' --output diagram.excalidraw
echo '{"title":"My Diagram",...}' | python generate_excalidraw.py --output diagram.excalidraw
python generate_excalidraw.py --spec-file spec.json --output diagram.excalidraw
Spec format:
{
"title": "Diagram Title",
"type": "flowchart|architecture|sequence|mindmap|er|swimlane|freeform|network|comparison",
"direction": "LR|TB|RL|BT",
"elements": [
{"id": "e1", "type": "rectangle", "label": "Step 1", "group": "optional"},
{"id": "e2", "type": "diamond", "label": "Decision?"},
{"id": "e3", "type": "ellipse", "label": "End"}
],
"connections": [
{"from": "e1", "to": "e2", "label": "next", "style": "arrow"},
{"from": "e2", "to": "e3", "label": "yes", "style": "arrow"}
],
"groups": [
{"name": "group-name", "label": "Group Label", "type": "frame"}
]
}
"""
import argparse
import json
import math
import random
import sys
import time
from pathlib import Path
# --- Constants ---
ELEMENT_SIZES = {
"rectangle": (200, 80),
"diamond": (140, 100),
"ellipse": (160, 60),
"text": (200, 30),
}
GAPS = {
"horizontal": 80,
"vertical": 60,
}
COLORS = {
"stroke": "#1e1e1e",
"bg_blue": "#a5d8ff",
"bg_green": "#b2f2bb",
"bg_red": "#ffc9c9",
"bg_yellow": "#ffec99",
"bg_purple": "#d0bfff",
"bg_gray": "#e9ecef",
}
BG_CYCLE = ["bg_blue", "bg_green", "bg_yellow", "bg_purple", "bg_red", "bg_gray"]
def generate_id(length=8):
"""Generate a random alphanumeric ID."""
chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return "".join(random.choice(chars) for _ in range(length))
def generate_seed():
"""Generate a random seed for hand-drawn rendering."""
return random.randint(100000, 999999999)
def now_ms():
"""Current time in milliseconds."""
return int(time.time() * 1000)
# --- Element Builders ---
def make_base_element(elem_type, x, y, width, height, **overrides):
"""Create a base element with all required properties."""
base = {
"id": generate_id(),
"type": elem_type,
"x": x,
"y": y,
"width": width,
"height": height,
"angle": 0,
"strokeColor": COLORS["stroke"],
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": None,
"roundness": {"type": 3} if elem_type in ("rectangle", "diamond", "ellipse") else None,
"seed": generate_seed(),
"version": 1,
"versionNonce": random.randint(1, 999999999),
"isDeleted": False,
"boundElements": [],
"updated": now_ms(),
"link": None,
"locked": False,
}
base.update(overrides)
return base
def make_shape(elem_type, x, y, label, width=None, height=None, bg_color=None):
"""Create a shape element (rectangle, diamond, ellipse) with bound text."""
default_w, default_h = ELEMENT_SIZES.get(elem_type, (200, 80))
w = width or default_w
h = height or default_h
# Auto-size based on text length
text_width = len(label) * 10 + 40
if text_width > w:
w = text_width
shape = make_base_element(elem_type, x, y, w, h)
if bg_color:
shape["backgroundColor"] = COLORS.get(bg_color, bg_color)
# Create bound text
text_elem = make_text(x, y, label, container_id=shape["id"], width=w, height=h)
shape["boundElements"] = [{"id": text_elem["id"], "type": "text"}]
return shape, text_elem
def make_text(x, y, text, container_id=None, width=200, height=80, font_size=20):
"""Create a text element, optionally bound to a container."""
text_height = 25
text_width = len(text) * 10
if container_id:
# Center text in container
tx = x + (width - text_width) / 2
ty = y + (height - text_height) / 2
else:
tx = x
ty = y
elem = make_base_element("text", tx, ty, text_width, text_height)
elem.update({
"text": text,
"fontSize": font_size,
"fontFamily": 1,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": container_id,
"originalText": text,
"autoResize": True,
"lineHeight": 1.25,
"roundness": None,
})
return elem
def make_arrow(x1, y1, x2, y2, start_id=None, end_id=None, label=None,
style="arrow", start_shape=None, end_shape=None):
"""Create an arrow/line element connecting two points or elements."""
dx = x2 - x1
dy = y2 - y1
width = abs(dx)
height = abs(dy)
elem = make_base_element("arrow", x1, y1, width, height)
elem.update({
"points": [[0, 0], [dx, dy]],
"startArrowhead": None,
"endArrowhead": "arrow" if style != "line" else None,
"lastCommittedPoint": None,
"roundness": {"type": 2},
})
if style == "dashed":
elem["strokeStyle"] = "dashed"
if start_id and start_shape:
sw, sh = start_shape["width"], start_shape["height"]
# Determine fixedPoint based on direction
fp = _compute_fixed_point(dx, dy, "start")
elem["startBinding"] = {
"elementId": start_id,
"focus": 0,
"gap": 1,
"fixedPoint": fp,
}
if end_id and end_shape:
fp = _compute_fixed_point(dx, dy, "end")
elem["endBinding"] = {
"elementId": end_id,
"focus": 0,
"gap": 1,
"fixedPoint": fp,
}
elements = [elem]
# Add label text on arrow if specified
if label:
mid_x = x1 + dx / 2
mid_y = y1 + dy / 2 - 15
label_elem = make_text(mid_x, mid_y, label, font_size=16)
elem["boundElements"] = [{"id": label_elem["id"], "type": "text"}]
label_elem["containerId"] = elem["id"]
elements.append(label_elem)
# Register arrow in shape boundElements
if start_id and start_shape:
if "boundElements" not in start_shape:
start_shape["boundElements"] = []
start_shape["boundElements"].append({"id": elem["id"], "type": "arrow"})
if end_id and end_shape:
if "boundElements" not in end_shape:
end_shape["boundElements"] = []
end_shape["boundElements"].append({"id": elem["id"], "type": "arrow"})
return elements
def _compute_fixed_point(dx, dy, end):
"""Compute fixedPoint for binding based on arrow direction."""
if abs(dx) > abs(dy):
# Horizontal arrow
if dx > 0:
return [1, 0.5] if end == "start" else [0, 0.5]
else:
return [0, 0.5] if end == "start" else [1, 0.5]
else:
# Vertical arrow
if dy > 0:
return [0.5, 1] if end == "start" else [0.5, 0]
else:
return [0.5, 0] if end == "start" else [0.5, 1]
def make_frame(x, y, width, height, name):
"""Create a frame element."""
elem = make_base_element("frame", x, y, width, height)
elem["name"] = name
elem["roundness"] = None
return elem
# --- Layout Engines ---
def layout_grid(elements, direction="LR", start_x=100, start_y=100):
"""
Position elements in a grid layout based on connection topology.
Returns dict mapping element ID to (x, y) position.
"""
positions = {}
n = len(elements)
if n == 0:
return positions
if direction in ("LR", "RL"):
# Arrange in rows with horizontal flow
cols = math.ceil(math.sqrt(n))
for i, elem in enumerate(elements):
col = i % cols
row = i // cols
ew, eh = ELEMENT_SIZES.get(elem.get("type", "rectangle"), (200, 80))
x = start_x + col * (ew + GAPS["horizontal"])
y = start_y + row * (eh + GAPS["vertical"])
if direction == "RL":
x = start_x + (cols - 1 - col) * (ew + GAPS["horizontal"])
positions[elem["id"]] = (x, y)
else:
# TB/BT — arrange in columns with vertical flow
rows = math.ceil(math.sqrt(n))
for i, elem in enumerate(elements):
row = i % rows
col = i // rows
ew, eh = ELEMENT_SIZES.get(elem.get("type", "rectangle"), (200, 80))
x = start_x + col * (ew + GAPS["horizontal"])
y = start_y + row * (eh + GAPS["vertical"])
if direction == "BT":
y = start_y + (rows - 1 - row) * (eh + GAPS["vertical"])
positions[elem["id"]] = (x, y)
return positions
def layout_linear(elements, connections, direction="LR", start_x=100, start_y=100):
"""
Position elements in a linear chain following connections.
Better for flowcharts than grid layout.
"""
if not elements:
return {}
# Build adjacency from connections
adj = {}
incoming = set()
for conn in connections:
adj.setdefault(conn["from"], []).append(conn["to"])
incoming.add(conn["to"])
# Find root nodes (no incoming connections)
all_ids = [e["id"] for e in elements]
roots = [eid for eid in all_ids if eid not in incoming]
if not roots:
roots = [all_ids[0]]
# BFS to determine levels
levels = {}
visited = set()
queue = [(r, 0) for r in roots]
for r in roots:
visited.add(r)
while queue:
node, level = queue.pop(0)
levels.setdefault(level, []).append(node)
for neighbor in adj.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, level + 1))
# Place unvisited nodes at the end
for eid in all_ids:
if eid not in visited:
max_level = max(levels.keys()) + 1 if levels else 0
levels.setdefault(max_level, []).append(eid)
# Compute positions
positions = {}
elem_lookup = {e["id"]: e for e in elements}
for level, nodes in sorted(levels.items()):
for lane, node_id in enumerate(nodes):
elem = elem_lookup.get(node_id, {})
ew, eh = ELEMENT_SIZES.get(elem.get("type", "rectangle"), (200, 80))
if direction in ("LR", "RL"):
x = start_x + level * (ew + GAPS["horizontal"])
y = start_y + lane * (eh + GAPS["vertical"])
if direction == "RL":
max_level = max(levels.keys())
x = start_x + (max_level - level) * (ew + GAPS["horizontal"])
else:
x = start_x + lane * (ew + GAPS["horizontal"])
y = start_y + level * (eh + GAPS["vertical"])
if direction == "BT":
max_level = max(levels.keys())
y = start_y + (max_level - level) * (eh + GAPS["vertical"])
positions[node_id] = (x, y)
return positions
def layout_radial(elements, connections, start_x=500, start_y=400):
"""Position elements in a radial layout for mind maps."""
if not elements:
return {}
positions = {}
center_id = elements[0]["id"]
positions[center_id] = (start_x, start_y)
# Find children of center
children = []
for conn in connections:
if conn["from"] == center_id:
children.append(conn["to"])
if not children:
# No connections — arrange all around center
children = [e["id"] for e in elements[1:]]
radius = 250
for i, child_id in enumerate(children):
angle = (2 * math.pi * i) / len(children) - math.pi / 2
x = start_x + radius * math.cos(angle)
y = start_y + radius * math.sin(angle)
positions[child_id] = (x, y)
# Place any remaining elements further out
remaining = [e["id"] for e in elements if e["id"] not in positions]
if remaining:
radius2 = 450
for i, eid in enumerate(remaining):
angle = (2 * math.pi * i) / len(remaining)
x = start_x + radius2 * math.cos(angle)
y = start_y + radius2 * math.sin(angle)
positions[eid] = (x, y)
return positions
# --- Main Generator ---
def generate_excalidraw(spec):
"""Generate a complete .excalidraw JSON from a diagram specification."""
diagram_type = spec.get("type", "flowchart")
direction = spec.get("direction", "TB" if diagram_type == "sequence" else "LR")
spec_elements = spec.get("elements", [])
spec_connections = spec.get("connections", [])
spec_groups = spec.get("groups", [])
# Choose layout engine
if diagram_type == "mindmap":
positions = layout_radial(spec_elements, spec_connections)
elif spec_connections:
positions = layout_linear(spec_elements, spec_connections, direction)
else:
positions = layout_grid(spec_elements, direction)
# Build Excalidraw elements
all_elements = []
shape_lookup = {} # id -> shape element (for arrow binding)
id_mapping = {} # spec id -> generated element id
# Create group frames first
group_id_map = {}
for group in spec_groups:
group_name = group["name"]
group_label = group.get("label", group_name)
# Find elements in this group to compute frame bounds
group_elem_ids = [e["id"] for e in spec_elements if e.get("group") == group_name]
if group_elem_ids:
min_x = min(positions.get(eid, (100, 100))[0] for eid in group_elem_ids) - 40
min_y = min(positions.get(eid, (100, 100))[1] for eid in group_elem_ids) - 50
max_x = max(positions.get(eid, (100, 100))[0] + 200 for eid in group_elem_ids) + 40
max_y = max(positions.get(eid, (100, 100))[1] + 80 for eid in group_elem_ids) + 40
frame = make_frame(min_x, min_y, max_x - min_x, max_y - min_y, group_label)
group_id_map[group_name] = frame["id"]
all_elements.append(frame)
# Create shape elements
bg_idx = 0
for spec_elem in spec_elements:
eid = spec_elem["id"]
etype = spec_elem.get("type", "rectangle")
label = spec_elem.get("label", "")
x, y = positions.get(eid, (100, 100))
# Pick background color
bg_color = spec_elem.get("color", None)
if not bg_color:
if etype == "diamond":
bg_color = "bg_yellow"
elif etype == "ellipse":
bg_color = "bg_gray"
else:
bg_color = BG_CYCLE[bg_idx % len(BG_CYCLE)]
bg_idx += 1
shape, text = make_shape(etype, x, y, label, bg_color=bg_color)
# Assign to frame if in a group
group_name = spec_elem.get("group")
if group_name and group_name in group_id_map:
shape["frameId"] = group_id_map[group_name]
text["frameId"] = group_id_map[group_name]
shape_lookup[eid] = shape
id_mapping[eid] = shape["id"]
all_elements.extend([shape, text])
# Create connections
for conn in spec_connections:
from_id = conn["from"]
to_id = conn["to"]
label = conn.get("label")
style = conn.get("style", "arrow")
from_shape = shape_lookup.get(from_id)
to_shape = shape_lookup.get(to_id)
if not from_shape or not to_shape:
print(f"Warning: skipping connection {from_id} -> {to_id}, element not found", file=sys.stderr)
continue
# Compute arrow start/end points (from shape centers/edges)
fx = from_shape["x"] + from_shape["width"] / 2
fy = from_shape["y"] + from_shape["height"] / 2
tx = to_shape["x"] + to_shape["width"] / 2
ty = to_shape["y"] + to_shape["height"] / 2
# Adjust to edges
dx = tx - fx
dy = ty - fy
if abs(dx) > abs(dy):
# Horizontal connection
if dx > 0:
sx = from_shape["x"] + from_shape["width"]
ex = to_shape["x"]
else:
sx = from_shape["x"]
ex = to_shape["x"] + to_shape["width"]
sy = fy
ey = ty
else:
# Vertical connection
sx = fx
ex = tx
if dy > 0:
sy = from_shape["y"] + from_shape["height"]
ey = to_shape["y"]
else:
sy = from_shape["y"]
ey = to_shape["y"] + to_shape["height"]
arrow_elems = make_arrow(
sx, sy, ex, ey,
start_id=from_shape["id"], end_id=to_shape["id"],
label=label, style=style,
start_shape=from_shape, end_shape=to_shape,
)
all_elements.extend(arrow_elems)
# Add title if specified
title = spec.get("title")
if title:
# Place title above the diagram
min_y = min(e["y"] for e in all_elements if "y" in e) if all_elements else 100
min_x = min(e["x"] for e in all_elements if "x" in e) if all_elements else 100
title_elem = make_text(min_x, min_y - 50, title, font_size=28)
all_elements.insert(0, title_elem)
# Assemble the document
doc = {
"type": "excalidraw",
"version": 2,
"source": "bmad-excalidraw",
"elements": all_elements,
"appState": {
"gridSize": None,
"viewBackgroundColor": "#ffffff",
},
"files": {},
}
return doc
def main():
parser = argparse.ArgumentParser(
description="Generate Excalidraw diagrams from a JSON specification",
)
parser.add_argument("--spec", help="JSON specification string")
parser.add_argument("--spec-file", help="Path to JSON specification file")
parser.add_argument("--output", "-o", required=True, help="Output .excalidraw file path")
parser.add_argument("--help-spec", action="store_true", help="Show specification format")
args = parser.parse_args()
if args.help_spec:
print(__doc__)
sys.exit(0)
# Read spec from args, file, or stdin
if args.spec:
spec = json.loads(args.spec)
elif args.spec_file:
spec = json.loads(Path(args.spec_file).read_text())
elif not sys.stdin.isatty():
spec = json.loads(sys.stdin.read())
else:
print("Error: provide --spec, --spec-file, or pipe JSON to stdin", file=sys.stderr)
sys.exit(2)
# Generate
doc = generate_excalidraw(spec)
# Write output
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(doc, indent=2))
print(json.dumps({
"status": "success",
"output": str(output_path),
"elements": len(doc["elements"]),
"type": spec.get("type", "flowchart"),
}))
if __name__ == "__main__":
main()
"""Tests for generate_excalidraw.py"""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from generate_excalidraw import (
generate_excalidraw,
generate_id,
layout_grid,
layout_linear,
layout_radial,
make_arrow,
make_base_element,
make_frame,
make_shape,
make_text,
)
class TestGenerateId:
def test_length(self):
assert len(generate_id()) == 8
def test_uniqueness(self):
ids = {generate_id() for _ in range(100)}
assert len(ids) == 100
def test_custom_length(self):
assert len(generate_id(12)) == 12
class TestMakeBaseElement:
def test_required_fields(self):
elem = make_base_element("rectangle", 10, 20, 200, 80)
assert elem["type"] == "rectangle"
assert elem["x"] == 10
assert elem["y"] == 20
assert elem["width"] == 200
assert elem["height"] == 80
assert elem["isDeleted"] is False
assert isinstance(elem["id"], str)
assert isinstance(elem["seed"], int)
def test_overrides(self):
elem = make_base_element("rectangle", 0, 0, 100, 50, strokeColor="#ff0000")
assert elem["strokeColor"] == "#ff0000"
class TestMakeShape:
def test_rectangle_with_text(self):
shape, text = make_shape("rectangle", 100, 100, "Hello")
assert shape["type"] == "rectangle"
assert text["type"] == "text"
assert text["text"] == "Hello"
assert text["containerId"] == shape["id"]
assert any(b["id"] == text["id"] for b in shape["boundElements"])
def test_diamond(self):
shape, text = make_shape("diamond", 0, 0, "Decision?")
assert shape["type"] == "diamond"
assert shape["width"] == 140 or shape["width"] > 140 # may auto-size
def test_ellipse(self):
shape, text = make_shape("ellipse", 0, 0, "Start")
assert shape["type"] == "ellipse"
def test_auto_size_long_text(self):
shape, text = make_shape("rectangle", 0, 0, "This is a very long label text")
assert shape["width"] >= 200 # should be wider than default
def test_background_color(self):
shape, _ = make_shape("rectangle", 0, 0, "Test", bg_color="bg_blue")
assert shape["backgroundColor"] == "#a5d8ff"
class TestMakeText:
def test_standalone_text(self):
text = make_text(50, 50, "Hello World")
assert text["type"] == "text"
assert text["text"] == "Hello World"
assert text["containerId"] is None
def test_bound_text(self):
text = make_text(50, 50, "Bound", container_id="shape1")
assert text["containerId"] == "shape1"
def test_font_size(self):
text = make_text(0, 0, "Title", font_size=28)
assert text["fontSize"] == 28
class TestMakeArrow:
def test_basic_arrow(self):
elems = make_arrow(0, 0, 100, 0)
assert len(elems) >= 1
arrow = elems[0]
assert arrow["type"] == "arrow"
assert arrow["points"] == [[0, 0], [100, 0]]
assert arrow["endArrowhead"] == "arrow"
def test_line_style(self):
elems = make_arrow(0, 0, 100, 0, style="line")
assert elems[0]["endArrowhead"] is None
def test_dashed_style(self):
elems = make_arrow(0, 0, 100, 0, style="dashed")
assert elems[0]["strokeStyle"] == "dashed"
def test_with_label(self):
elems = make_arrow(0, 0, 100, 0, label="yes")
assert len(elems) == 2 # arrow + label text
assert elems[1]["type"] == "text"
assert elems[1]["text"] == "yes"
def test_with_bindings(self):
shape1 = make_base_element("rectangle", 0, 0, 200, 80)
shape1["boundElements"] = []
shape2 = make_base_element("rectangle", 300, 0, 200, 80)
shape2["boundElements"] = []
elems = make_arrow(
200, 40, 300, 40,
start_id=shape1["id"], end_id=shape2["id"],
start_shape=shape1, end_shape=shape2,
)
arrow = elems[0]
assert arrow["startBinding"]["elementId"] == shape1["id"]
assert arrow["endBinding"]["elementId"] == shape2["id"]
# Check shapes got updated
assert any(b["id"] == arrow["id"] for b in shape1["boundElements"])
assert any(b["id"] == arrow["id"] for b in shape2["boundElements"])
class TestMakeFrame:
def test_frame(self):
frame = make_frame(0, 0, 500, 400, "My Frame")
assert frame["type"] == "frame"
assert frame["name"] == "My Frame"
assert frame["width"] == 500
class TestLayoutGrid:
def test_empty(self):
assert layout_grid([]) == {}
def test_single_element(self):
elems = [{"id": "e1", "type": "rectangle"}]
pos = layout_grid(elems)
assert "e1" in pos
def test_multiple_elements_lr(self):
elems = [{"id": f"e{i}", "type": "rectangle"} for i in range(4)]
pos = layout_grid(elems, direction="LR")
assert len(pos) == 4
# Elements should have distinct positions
positions = list(pos.values())
assert len(set(positions)) == 4
def test_tb_direction(self):
elems = [{"id": f"e{i}", "type": "rectangle"} for i in range(4)]
pos = layout_grid(elems, direction="TB")
assert len(pos) == 4
class TestLayoutLinear:
def test_simple_chain(self):
elems = [
{"id": "a", "type": "rectangle"},
{"id": "b", "type": "rectangle"},
{"id": "c", "type": "rectangle"},
]
conns = [
{"from": "a", "to": "b"},
{"from": "b", "to": "c"},
]
pos = layout_linear(elems, conns, direction="LR")
assert pos["a"][0] < pos["b"][0] < pos["c"][0] # Left to right
def test_branching(self):
elems = [
{"id": "a", "type": "rectangle"},
{"id": "b", "type": "rectangle"},
{"id": "c", "type": "rectangle"},
]
conns = [
{"from": "a", "to": "b"},
{"from": "a", "to": "c"},
]
pos = layout_linear(elems, conns, direction="LR")
# b and c should be at same x level
assert pos["b"][0] == pos["c"][0]
# but different y
assert pos["b"][1] != pos["c"][1]
def test_tb_direction(self):
elems = [
{"id": "a", "type": "rectangle"},
{"id": "b", "type": "rectangle"},
]
conns = [{"from": "a", "to": "b"}]
pos = layout_linear(elems, conns, direction="TB")
assert pos["a"][1] < pos["b"][1] # Top to bottom
def test_empty(self):
assert layout_linear([], []) == {}
def test_disconnected_elements(self):
elems = [
{"id": "a", "type": "rectangle"},
{"id": "b", "type": "rectangle"},
{"id": "c", "type": "rectangle"},
]
conns = [{"from": "a", "to": "b"}]
pos = layout_linear(elems, conns, direction="LR")
assert len(pos) == 3 # All elements placed including disconnected
class TestLayoutRadial:
def test_basic(self):
elems = [
{"id": "center", "type": "ellipse"},
{"id": "a", "type": "rectangle"},
{"id": "b", "type": "rectangle"},
]
conns = [
{"from": "center", "to": "a"},
{"from": "center", "to": "b"},
]
pos = layout_radial(elems, conns)
# Center should be at the center position
assert pos["center"] == (500, 400)
def test_empty(self):
assert layout_radial([], []) == {}
class TestGenerateExcalidraw:
def test_basic_flowchart(self):
spec = {
"title": "Test Flow",
"type": "flowchart",
"direction": "LR",
"elements": [
{"id": "start", "type": "ellipse", "label": "Start"},
{"id": "step1", "type": "rectangle", "label": "Step 1"},
{"id": "end", "type": "ellipse", "label": "End"},
],
"connections": [
{"from": "start", "to": "step1", "style": "arrow"},
{"from": "step1", "to": "end", "style": "arrow"},
],
}
doc = generate_excalidraw(spec)
assert doc["type"] == "excalidraw"
assert doc["version"] == 2
assert isinstance(doc["elements"], list)
assert len(doc["elements"]) > 0
# Check element types exist
types = {e["type"] for e in doc["elements"]}
assert "ellipse" in types
assert "rectangle" in types
assert "arrow" in types
assert "text" in types
def test_with_groups(self):
spec = {
"type": "architecture",
"elements": [
{"id": "svc1", "type": "rectangle", "label": "Service A", "group": "backend"},
{"id": "svc2", "type": "rectangle", "label": "Service B", "group": "backend"},
],
"connections": [],
"groups": [
{"name": "backend", "label": "Backend Services", "type": "frame"},
],
}
doc = generate_excalidraw(spec)
types = {e["type"] for e in doc["elements"]}
assert "frame" in types
def test_with_decision(self):
spec = {
"type": "flowchart",
"elements": [
{"id": "start", "type": "rectangle", "label": "Begin"},
{"id": "decide", "type": "diamond", "label": "OK?"},
{"id": "yes", "type": "rectangle", "label": "Continue"},
{"id": "no", "type": "rectangle", "label": "Stop"},
],
"connections": [
{"from": "start", "to": "decide"},
{"from": "decide", "to": "yes", "label": "Yes"},
{"from": "decide", "to": "no", "label": "No"},
],
}
doc = generate_excalidraw(spec)
diamonds = [e for e in doc["elements"] if e["type"] == "diamond"]
assert len(diamonds) == 1
def test_mindmap(self):
spec = {
"type": "mindmap",
"elements": [
{"id": "center", "type": "ellipse", "label": "Main Idea"},
{"id": "b1", "type": "rectangle", "label": "Branch 1"},
{"id": "b2", "type": "rectangle", "label": "Branch 2"},
],
"connections": [
{"from": "center", "to": "b1"},
{"from": "center", "to": "b2"},
],
}
doc = generate_excalidraw(spec)
assert doc["type"] == "excalidraw"
assert len(doc["elements"]) > 0
def test_output_to_file(self):
spec = {
"type": "flowchart",
"elements": [{"id": "a", "type": "rectangle", "label": "A"}],
"connections": [],
}
doc = generate_excalidraw(spec)
with tempfile.NamedTemporaryFile(suffix=".excalidraw", delete=False, mode="w") as f:
json.dump(doc, f)
tmp_path = f.name
# Verify it's valid JSON
loaded = json.loads(Path(tmp_path).read_text())
assert loaded["type"] == "excalidraw"
Path(tmp_path).unlink()
def test_no_title(self):
spec = {
"type": "flowchart",
"elements": [{"id": "a", "type": "rectangle", "label": "A"}],
"connections": [],
}
doc = generate_excalidraw(spec)
# Should not have a title text element
texts = [e for e in doc["elements"] if e["type"] == "text" and e.get("containerId") is None]
assert len(texts) == 0
def test_empty_spec(self):
spec = {"type": "flowchart", "elements": [], "connections": []}
doc = generate_excalidraw(spec)
assert doc["type"] == "excalidraw"
assert doc["elements"] == []
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
"""Tests for validate_excalidraw.py"""
import json
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from validate_excalidraw import validate
def _write_temp(data):
"""Write data to a temp .excalidraw file and return the path."""
with tempfile.NamedTemporaryFile(suffix=".excalidraw", delete=False, mode="w") as f:
json.dump(data, f)
return f.name
def _valid_doc():
"""Return a minimal valid .excalidraw document."""
return {
"type": "excalidraw",
"version": 2,
"source": "test",
"elements": [
{
"id": "rect1",
"type": "rectangle",
"x": 100,
"y": 100,
"width": 200,
"height": 80,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": None,
"roundness": {"type": 3},
"seed": 12345,
"version": 1,
"versionNonce": 67890,
"isDeleted": False,
"boundElements": [],
"updated": 1700000000000,
"link": None,
"locked": False,
}
],
"appState": {"gridSize": None, "viewBackgroundColor": "#ffffff"},
"files": {},
}
class TestValidDoc:
def test_valid_passes(self):
path = _write_temp(_valid_doc())
result = validate(path)
assert result["status"] == "pass"
assert result["summary"]["total"] == 0
Path(path).unlink()
class TestInvalidJson:
def test_bad_json(self):
with tempfile.NamedTemporaryFile(suffix=".excalidraw", delete=False, mode="w") as f:
f.write("{invalid json")
path = f.name
result = validate(path)
assert any(f["severity"] == "critical" for f in result)
Path(path).unlink()
def test_file_not_found(self):
result = validate("/nonexistent/file.excalidraw")
assert any(f["severity"] == "critical" for f in result)
class TestStructure:
def test_wrong_type(self):
doc = _valid_doc()
doc["type"] = "not-excalidraw"
path = _write_temp(doc)
result = validate(path)
assert result["status"] == "fail"
assert any("type" in f["issue"].lower() for f in result["findings"])
Path(path).unlink()
def test_missing_elements(self):
doc = _valid_doc()
del doc["elements"]
path = _write_temp(doc)
result = validate(path)
assert result["status"] == "fail"
Path(path).unlink()
def test_elements_not_array(self):
doc = _valid_doc()
doc["elements"] = "not an array"
path = _write_temp(doc)
result = validate(path)
assert result["status"] == "fail"
Path(path).unlink()
class TestElements:
def test_missing_id(self):
doc = _valid_doc()
del doc["elements"][0]["id"]
path = _write_temp(doc)
result = validate(path)
assert any(f["severity"] == "critical" and "id" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_duplicate_id(self):
doc = _valid_doc()
elem2 = dict(doc["elements"][0]) # same ID
doc["elements"].append(elem2)
path = _write_temp(doc)
result = validate(path)
assert any("uplicate" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_invalid_type(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "banana"
path = _write_temp(doc)
result = validate(path)
assert any("nvalid element type" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_missing_position(self):
doc = _valid_doc()
del doc["elements"][0]["x"]
path = _write_temp(doc)
result = validate(path)
assert any("'x'" in f["issue"] for f in result["findings"])
Path(path).unlink()
class TestTextElements:
def test_text_missing_text_field(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "text"
# No 'text' field
path = _write_temp(doc)
result = validate(path)
assert any("text" in f["issue"].lower() and f["severity"] == "high" for f in result["findings"])
Path(path).unlink()
def test_invalid_text_align(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "text"
doc["elements"][0]["text"] = "Hello"
doc["elements"][0]["textAlign"] = "justify"
path = _write_temp(doc)
result = validate(path)
assert any("textAlign" in f["issue"] for f in result["findings"])
Path(path).unlink()
class TestLinearElements:
def test_arrow_missing_points(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "arrow"
path = _write_temp(doc)
result = validate(path)
assert any("points" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_arrow_insufficient_points(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "arrow"
doc["elements"][0]["points"] = [[0, 0]]
path = _write_temp(doc)
result = validate(path)
assert any("2 points" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_binding_to_nonexistent_element(self):
doc = _valid_doc()
doc["elements"][0]["type"] = "arrow"
doc["elements"][0]["points"] = [[0, 0], [100, 0]]
doc["elements"][0]["startBinding"] = {
"elementId": "nonexistent",
"focus": 0,
"gap": 1,
}
path = _write_temp(doc)
result = validate(path)
assert any("non-existent" in f["issue"] for f in result["findings"])
Path(path).unlink()
class TestStyleValidation:
def test_invalid_fill_style(self):
doc = _valid_doc()
doc["elements"][0]["fillStyle"] = "polka-dots"
path = _write_temp(doc)
result = validate(path)
assert any("fillStyle" in f["issue"] for f in result["findings"])
Path(path).unlink()
def test_invalid_stroke_style(self):
doc = _valid_doc()
doc["elements"][0]["strokeStyle"] = "wavy"
path = _write_temp(doc)
result = validate(path)
assert any("strokeStyle" in f["issue"] for f in result["findings"])
Path(path).unlink()
class TestSummary:
def test_summary_counts(self):
doc = _valid_doc()
del doc["elements"][0]["id"] # critical
doc["elements"][0]["fillStyle"] = "invalid" # low
path = _write_temp(doc)
result = validate(path)
assert result["summary"]["total"] >= 2
assert result["summary"]["critical"] >= 1
Path(path).unlink()
def test_warning_status(self):
doc = _valid_doc()
# Add arrow with binding to nonexistent element (medium)
doc["elements"].append({
"id": "arrow1",
"type": "arrow",
"x": 0, "y": 0, "width": 100, "height": 0,
"points": [[0, 0], [100, 0]],
"startBinding": {"elementId": "ghost", "focus": 0, "gap": 1},
})
path = _write_temp(doc)
result = validate(path)
assert result["status"] == "warning"
Path(path).unlink()
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])
# /// script
# requires-python = ">=3.9"
# ///
"""
Excalidraw File Validator
Validates .excalidraw files for structural correctness.
Usage:
python validate_excalidraw.py path/to/diagram.excalidraw
python validate_excalidraw.py path/to/diagram.excalidraw -o report.json
Exit codes: 0=pass, 1=fail, 2=error
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
VALID_ELEMENT_TYPES = {
"rectangle", "ellipse", "diamond", "arrow", "line",
"freedraw", "text", "image", "frame",
}
VALID_FILL_STYLES = {"solid", "hachure", "cross-hatch", "zigzag", "dots", "dashed", "zigzag-line"}
VALID_STROKE_STYLES = {"solid", "dashed", "dotted"}
VALID_TEXT_ALIGN = {"left", "center", "right"}
VALID_VERTICAL_ALIGN = {"top", "middle"}
VALID_ARROWHEADS = {None, "arrow", "bar", "dot", "triangle"}
def validate(file_path):
"""Validate an .excalidraw file and return findings."""
findings = []
try:
data = json.loads(Path(file_path).read_text())
except json.JSONDecodeError as e:
findings.append({
"severity": "critical",
"category": "parse",
"location": {"file": str(file_path)},
"issue": f"Invalid JSON: {e}",
"fix": "Fix JSON syntax errors",
})
return findings
except FileNotFoundError:
findings.append({
"severity": "critical",
"category": "file",
"location": {"file": str(file_path)},
"issue": "File not found",
"fix": "Check the file path",
})
return findings
# Top-level structure
if data.get("type") != "excalidraw":
findings.append({
"severity": "critical",
"category": "structure",
"location": {"file": str(file_path)},
"issue": f"Invalid type field: '{data.get('type')}', expected 'excalidraw'",
"fix": "Set type to 'excalidraw'",
})
if "elements" not in data:
findings.append({
"severity": "critical",
"category": "structure",
"location": {"file": str(file_path)},
"issue": "Missing 'elements' array",
"fix": "Add 'elements' array",
})
return _build_result(file_path, findings)
if not isinstance(data["elements"], list):
findings.append({
"severity": "critical",
"category": "structure",
"location": {"file": str(file_path)},
"issue": "'elements' must be an array",
"fix": "Change 'elements' to an array",
})
return _build_result(file_path, findings)
# Validate elements
seen_ids = set()
element_ids = {e.get("id") for e in data["elements"] if e.get("id")}
for i, elem in enumerate(data["elements"]):
loc = {"file": str(file_path), "element_index": i, "element_id": elem.get("id")}
# Required fields
if "id" not in elem:
findings.append({
"severity": "critical",
"category": "element",
"location": loc,
"issue": f"Element {i} missing 'id'",
"fix": "Add unique 'id' to element",
})
elif elem["id"] in seen_ids:
findings.append({
"severity": "critical",
"category": "element",
"location": loc,
"issue": f"Duplicate element ID: '{elem['id']}'",
"fix": "Use unique IDs for all elements",
})
else:
seen_ids.add(elem["id"])
if "type" not in elem:
findings.append({
"severity": "critical",
"category": "element",
"location": loc,
"issue": f"Element {i} missing 'type'",
"fix": "Add valid element type",
})
elif elem["type"] not in VALID_ELEMENT_TYPES:
findings.append({
"severity": "high",
"category": "element",
"location": loc,
"issue": f"Invalid element type: '{elem['type']}'",
"fix": f"Use one of: {', '.join(sorted(VALID_ELEMENT_TYPES))}",
})
# Position and size
for field in ("x", "y", "width", "height"):
if field not in elem:
findings.append({
"severity": "high",
"category": "element",
"location": loc,
"issue": f"Element missing '{field}'",
"fix": f"Add '{field}' (number)",
})
# Type-specific validation
if elem.get("type") == "text":
if "text" not in elem:
findings.append({
"severity": "high",
"category": "text",
"location": loc,
"issue": "Text element missing 'text' field",
"fix": "Add 'text' field with content",
})
if elem.get("textAlign") and elem["textAlign"] not in VALID_TEXT_ALIGN:
findings.append({
"severity": "medium",
"category": "text",
"location": loc,
"issue": f"Invalid textAlign: '{elem['textAlign']}'",
"fix": f"Use one of: {', '.join(VALID_TEXT_ALIGN)}",
})
if elem.get("type") in ("arrow", "line"):
if "points" not in elem:
findings.append({
"severity": "high",
"category": "linear",
"location": loc,
"issue": "Arrow/line missing 'points' array",
"fix": "Add 'points' array with at least 2 points",
})
elif len(elem.get("points", [])) < 2:
findings.append({
"severity": "high",
"category": "linear",
"location": loc,
"issue": "Arrow/line needs at least 2 points",
"fix": "Add start and end points: [[0,0],[dx,dy]]",
})
# Validate bindings reference existing elements
for binding_key in ("startBinding", "endBinding"):
binding = elem.get(binding_key)
if binding and isinstance(binding, dict):
ref_id = binding.get("elementId")
if ref_id and ref_id not in element_ids:
findings.append({
"severity": "medium",
"category": "binding",
"location": loc,
"issue": f"{binding_key} references non-existent element '{ref_id}'",
"fix": "Fix element ID reference or remove binding",
})
# Validate style properties
if elem.get("fillStyle") and elem["fillStyle"] not in VALID_FILL_STYLES:
findings.append({
"severity": "low",
"category": "style",
"location": loc,
"issue": f"Non-standard fillStyle: '{elem['fillStyle']}'",
"fix": f"Use one of: {', '.join(sorted(VALID_FILL_STYLES))}",
})
if elem.get("strokeStyle") and elem["strokeStyle"] not in VALID_STROKE_STYLES:
findings.append({
"severity": "low",
"category": "style",
"location": loc,
"issue": f"Non-standard strokeStyle: '{elem['strokeStyle']}'",
"fix": f"Use one of: {', '.join(sorted(VALID_STROKE_STYLES))}",
})
return _build_result(file_path, findings)
def _build_result(file_path, findings):
"""Build the structured validation result."""
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
for f in findings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + 1
status = "pass"
if severity_counts["critical"] > 0 or severity_counts["high"] > 0:
status = "fail"
elif severity_counts["medium"] > 0:
status = "warning"
return {
"script": "validate-excalidraw",
"version": "1.0.0",
"skill_path": str(file_path),
"timestamp": datetime.now(timezone.utc).isoformat(),
"status": status,
"findings": findings,
"summary": {
"total": len(findings),
**severity_counts,
},
}
def main():
parser = argparse.ArgumentParser(description="Validate .excalidraw files")
parser.add_argument("file", help="Path to .excalidraw file")
parser.add_argument("-o", "--output", help="Output report to file (JSON)")
args = parser.parse_args()
result = validate(args.file)
output = json.dumps(result, indent=2)
if args.output:
Path(args.output).write_text(output)
print(f"Report written to {args.output}", file=sys.stderr)
else:
print(output)
if result["status"] == "fail":
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Related skills
FAQ
What diagram types does bmad-excalidraw support?
Its catalog covers flowcharts, architecture, sequence, mind map, entity relationship, swimlane, data flow, wireframe/mockup, network/topology, and comparison/matrix diagrams.
Can it run without questions?
Yes. Passing --headless or -H skips questions, infers the diagram type and content, and saves the file, so it works in non-interactive pipelines.