feat(excalidraw): improve diagram quality with canvas measurement, validation, and conventions
Replace the charCount * fontSize * 0.55 text sizing heuristic with canvas-based measurement (graceful fallback when native deps unavailable). Add validate.mjs for automated spatial checks (text overflow, arrow-text collisions, element overlap). Update element format reference with sizing rules, label guidelines, and arrow routing conventions. Add verification step to SKILL.md workflow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
name: excalidraw-png-export
|
||||||
|
description: "This skill should be used when creating diagrams, architecture visuals, or flowcharts and exporting them as PNG files. It uses the Excalidraw MCP to render hand-drawn style diagrams locally and Playwright to export them to PNG without sending data to any remote server. Triggers on requests like 'create a diagram', 'make an architecture diagram', 'draw a flowchart and export as PNG', or any request that needs a visual diagram delivered as an image file."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Excalidraw PNG Export
|
||||||
|
|
||||||
|
Create hand-drawn style diagrams with the Excalidraw MCP and export them locally to PNG files. All rendering happens on the local machine. Diagram data never leaves the user's computer.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### First-Time Setup
|
||||||
|
|
||||||
|
Run the setup script once per machine to install Playwright and Chromium headless:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <skill-path>/scripts/setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a `.export-runtime` directory inside `scripts/` with the Node.js dependencies. The setup is idempotent and skips installation if already present.
|
||||||
|
|
||||||
|
### Required MCP
|
||||||
|
|
||||||
|
The Excalidraw MCP server must be configured. Verify availability by checking for `mcp__excalidraw__create_view` and `mcp__excalidraw__read_checkpoint` tools.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### Step 1: Design the Diagram Elements
|
||||||
|
|
||||||
|
Translate the user's request into Excalidraw element JSON. Load [excalidraw-element-format.md](./references/excalidraw-element-format.md) for the full element specification, color palette, and sizing guidelines.
|
||||||
|
|
||||||
|
Key design decisions:
|
||||||
|
- Choose appropriate colors from the palette to distinguish different components
|
||||||
|
- Use `label` on shapes instead of separate text elements
|
||||||
|
- Use `roundness: { type: 3 }` for rounded corners on rectangles
|
||||||
|
- Include `cameraUpdate` as the first element to frame the view (MCP rendering only)
|
||||||
|
- Use arrow bindings (`startBinding`/`endBinding`) to connect shapes
|
||||||
|
|
||||||
|
### Step 2: Render with Excalidraw MCP
|
||||||
|
|
||||||
|
Call `mcp__excalidraw__create_view` with the element JSON array. This renders an interactive preview in the Claude Code UI.
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__excalidraw__create_view({ elements: "<JSON array string>" })
|
||||||
|
```
|
||||||
|
|
||||||
|
The response includes a `checkpointId` for retrieving the rendered state.
|
||||||
|
|
||||||
|
### Step 3: Extract the Checkpoint Data
|
||||||
|
|
||||||
|
Call `mcp__excalidraw__read_checkpoint` with the checkpoint ID to get the full element JSON back.
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__excalidraw__read_checkpoint({ id: "<checkpointId>" })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Convert Checkpoint to .excalidraw File
|
||||||
|
|
||||||
|
Use the `convert.mjs` script to transform raw MCP checkpoint JSON into a valid `.excalidraw` file. This handles all the tedious parts automatically:
|
||||||
|
|
||||||
|
- Filters out pseudo-elements (`cameraUpdate`, `delete`, `restoreCheckpoint`)
|
||||||
|
- Adds required Excalidraw defaults (`seed`, `version`, `fontFamily`, etc.)
|
||||||
|
- Expands `label` properties on shapes/arrows into proper bound text elements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save checkpoint JSON to a file first, then convert:
|
||||||
|
node <skill-path>/scripts/convert.mjs <input.json> <output.excalidraw>
|
||||||
|
```
|
||||||
|
|
||||||
|
The input JSON should be the raw checkpoint data from `mcp__excalidraw__read_checkpoint` (the `{"elements": [...]}` object).
|
||||||
|
|
||||||
|
**For batch exports**: Write each checkpoint to a separate raw JSON file, then convert each one:
|
||||||
|
```bash
|
||||||
|
node <skill-path>/scripts/convert.mjs raw1.json diagram1.excalidraw
|
||||||
|
node <skill-path>/scripts/convert.mjs raw2.json diagram2.excalidraw
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual alternative**: If you need to write the `.excalidraw` file by hand (e.g., without the convert script), each element needs these defaults:
|
||||||
|
|
||||||
|
```
|
||||||
|
angle: 0, roughness: 1, opacity: 100, groupIds: [], seed: <unique int>,
|
||||||
|
version: 1, versionNonce: <unique int>, isDeleted: false,
|
||||||
|
boundElements: null, link: null, locked: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Text elements also need: `fontFamily: 1, textAlign: "left", verticalAlign: "top", baseline: 14, containerId: null, originalText: "<same as text>"`
|
||||||
|
|
||||||
|
Bound text (labels on shapes/arrows) needs: `containerId: "<parent-id>"`, `textAlign: "center"`, `verticalAlign: "middle"`, and the parent needs `boundElements: [{"id": "<text-id>", "type": "text"}]`.
|
||||||
|
|
||||||
|
### Step 5: Export to PNG
|
||||||
|
|
||||||
|
Run the export script. Determine the runtime path relative to this skill's scripts directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <skill-path>/scripts/.export-runtime && node <skill-path>/scripts/export_png.mjs /tmp/excalidraw-export/diagram.excalidraw /tmp/excalidraw-export/output.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The script:
|
||||||
|
1. Starts a local HTTP server serving the `.excalidraw` file and an HTML page
|
||||||
|
2. Launches headless Chromium via Playwright
|
||||||
|
3. The HTML page loads the Excalidraw library from esm.sh (library code only, not user data)
|
||||||
|
4. Calls `exportToBlob` on the local diagram data
|
||||||
|
5. Extracts the base64 PNG and writes it to disk
|
||||||
|
6. Cleans up temp files and exits
|
||||||
|
|
||||||
|
The script prints the output path on success. Verify the result with `file <output.png>`.
|
||||||
|
|
||||||
|
### Step 5.5: Validate and Iterate
|
||||||
|
|
||||||
|
Run the validation script on the `.excalidraw` file to catch spatial issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node <skill-path>/scripts/validate.mjs /tmp/excalidraw-export/diagram.excalidraw
|
||||||
|
```
|
||||||
|
|
||||||
|
Then read the exported PNG back using the Read tool to visually inspect:
|
||||||
|
|
||||||
|
1. All label text fits within its container (no overflow/clipping)
|
||||||
|
2. No arrows cross over text labels
|
||||||
|
3. Spacing between elements is consistent
|
||||||
|
4. Legend and titles are properly positioned
|
||||||
|
|
||||||
|
If the validation script or visual inspection reveals issues:
|
||||||
|
1. Identify the specific elements that need adjustment
|
||||||
|
2. Edit the `.excalidraw` file (adjust coordinates, box sizes, or arrow waypoints)
|
||||||
|
3. Re-run the export script (Step 5)
|
||||||
|
4. Re-validate
|
||||||
|
|
||||||
|
### Step 6: Deliver the Result
|
||||||
|
|
||||||
|
Read the PNG file to display it to the user. Provide the file path so the user can access it directly.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Setup fails**: Verify Node.js v18+ is installed (`node --version`). Ensure npm has network access for the initial Playwright/Chromium download.
|
||||||
|
|
||||||
|
**Export times out**: The HTML page has a 30-second timeout. If it fails, check browser console output in the script's error messages. Common cause: esm.sh CDN is temporarily slow on first load.
|
||||||
|
|
||||||
|
**Blank PNG**: Ensure elements include all required properties (see Step 4 defaults). Missing `seed`, `version`, or `fontFamily` on text elements can cause silent render failures.
|
||||||
|
|
||||||
|
**"READY" never fires**: The `exportToBlob` call requires valid elements. Filter out `cameraUpdate` and other pseudo-elements before writing the `.excalidraw` file.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Excalidraw Element Format Reference
|
||||||
|
|
||||||
|
This reference documents the element JSON format accepted by the Excalidraw MCP `create_view` tool and the `export_png.mjs` script.
|
||||||
|
|
||||||
|
## Color Palette
|
||||||
|
|
||||||
|
### Primary Colors
|
||||||
|
| Name | Hex | Use |
|
||||||
|
|------|-----|-----|
|
||||||
|
| Blue | `#4a9eed` | Primary actions, links |
|
||||||
|
| Amber | `#f59e0b` | Warnings, highlights |
|
||||||
|
| Green | `#22c55e` | Success, positive |
|
||||||
|
| Red | `#ef4444` | Errors, negative |
|
||||||
|
| Purple | `#8b5cf6` | Accents, special |
|
||||||
|
| Pink | `#ec4899` | Decorative |
|
||||||
|
| Cyan | `#06b6d4` | Info, secondary |
|
||||||
|
|
||||||
|
### Fill Colors (pastel, for shape backgrounds)
|
||||||
|
| Color | Hex | Good For |
|
||||||
|
|-------|-----|----------|
|
||||||
|
| Light Blue | `#a5d8ff` | Input, sources, primary |
|
||||||
|
| Light Green | `#b2f2bb` | Success, output |
|
||||||
|
| Light Orange | `#ffd8a8` | Warning, pending |
|
||||||
|
| Light Purple | `#d0bfff` | Processing, middleware |
|
||||||
|
| Light Red | `#ffc9c9` | Error, critical |
|
||||||
|
| Light Yellow | `#fff3bf` | Notes, decisions |
|
||||||
|
| Light Teal | `#c3fae8` | Storage, data |
|
||||||
|
|
||||||
|
## Element Types
|
||||||
|
|
||||||
|
### Required Fields (all elements)
|
||||||
|
`type`, `id` (unique string), `x`, `y`, `width`, `height`
|
||||||
|
|
||||||
|
### Defaults (skip these)
|
||||||
|
strokeColor="#1e1e1e", backgroundColor="transparent", fillStyle="solid", strokeWidth=2, roughness=1, opacity=100
|
||||||
|
|
||||||
|
### Shapes
|
||||||
|
|
||||||
|
**Rectangle**: `{ "type": "rectangle", "id": "r1", "x": 100, "y": 100, "width": 200, "height": 100 }`
|
||||||
|
- `roundness: { type: 3 }` for rounded corners
|
||||||
|
- `backgroundColor: "#a5d8ff"`, `fillStyle: "solid"` for filled
|
||||||
|
|
||||||
|
**Ellipse**: `{ "type": "ellipse", "id": "e1", "x": 100, "y": 100, "width": 150, "height": 150 }`
|
||||||
|
|
||||||
|
**Diamond**: `{ "type": "diamond", "id": "d1", "x": 100, "y": 100, "width": 150, "height": 150 }`
|
||||||
|
|
||||||
|
### Labels
|
||||||
|
|
||||||
|
**Labeled shape (preferred)**: Add `label` to any shape for auto-centered text.
|
||||||
|
```json
|
||||||
|
{ "type": "rectangle", "id": "r1", "x": 100, "y": 100, "width": 200, "height": 80, "label": { "text": "Hello", "fontSize": 20 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Standalone text** (titles, annotations only):
|
||||||
|
```json
|
||||||
|
{ "type": "text", "id": "t1", "x": 150, "y": 138, "text": "Hello", "fontSize": 20 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arrows
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 200, "height": 0, "points": [[0,0],[200,0]], "endArrowhead": "arrow" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Bindings** connect arrows to shapes:
|
||||||
|
```json
|
||||||
|
"startBinding": { "elementId": "r1", "fixedPoint": [1, 0.5] }
|
||||||
|
```
|
||||||
|
fixedPoint: top=[0.5,0], bottom=[0.5,1], left=[0,0.5], right=[1,0.5]
|
||||||
|
|
||||||
|
**Labeled arrow**: `"label": { "text": "connects" }`
|
||||||
|
|
||||||
|
### Camera (MCP only, not exported to PNG)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 0, "y": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Camera sizes must be 4:3 ratio. The export script filters these out automatically.
|
||||||
|
|
||||||
|
## Sizing Rules
|
||||||
|
|
||||||
|
### Container-to-text ratios
|
||||||
|
- Box width >= estimated_text_width * 1.4 (40% horizontal margin)
|
||||||
|
- Box height >= estimated_text_height * 1.5 (50% vertical margin)
|
||||||
|
- Minimum box size: 150x60 for single-line labels, 200x80 for multi-line
|
||||||
|
|
||||||
|
### Font size constraints
|
||||||
|
- Labels inside containers: max fontSize 14
|
||||||
|
- Service/zone titles: fontSize 18-22
|
||||||
|
- Standalone annotations: fontSize 12-14
|
||||||
|
- Never exceed fontSize 16 inside a box smaller than 300px wide
|
||||||
|
|
||||||
|
### Padding
|
||||||
|
- Minimum 15px padding on each side between text and container edge
|
||||||
|
- For multi-line text, add 8px vertical padding per line beyond the first
|
||||||
|
|
||||||
|
### General
|
||||||
|
- Leave 20-30px gaps between elements
|
||||||
|
|
||||||
|
## Label Content Guidelines
|
||||||
|
|
||||||
|
### Keep labels short
|
||||||
|
- Maximum 2 lines per label inside shapes
|
||||||
|
- Maximum 25 characters per line
|
||||||
|
- If label needs 3+ lines, split: short name in box, details as annotation below
|
||||||
|
|
||||||
|
### Label patterns
|
||||||
|
- Service box: "Service Name" (1 line) or "Service Name\nBrief role" (2 lines)
|
||||||
|
- Component box: "Component Name" (1 line)
|
||||||
|
- Detail text: Use standalone text elements positioned below/beside the box
|
||||||
|
|
||||||
|
### Bad vs Good
|
||||||
|
BAD: label "Auth-MS\nOAuth tokens, credentials\n800-1K req/s, <100ms" (3 lines, 30+ chars)
|
||||||
|
GOOD: label "Auth-MS\nOAuth token management" (2 lines, 22 chars max)
|
||||||
|
+ standalone text below: "800-1K req/s, <100ms p99"
|
||||||
|
|
||||||
|
## Arrow Routing Rules
|
||||||
|
|
||||||
|
### Gutter-based routing
|
||||||
|
- Define horizontal and vertical gutters (20-30px gaps between service zones)
|
||||||
|
- Route arrows through gutters, never over content areas
|
||||||
|
- Use right-angle waypoints along zone edges
|
||||||
|
|
||||||
|
### Waypoint placement
|
||||||
|
- Start/end points: attach to box edges using fixedPoint bindings
|
||||||
|
- Mid-waypoints: offset 20px from nearest box edge
|
||||||
|
- For crossing traffic: stagger parallel arrows by 10px
|
||||||
|
|
||||||
|
### Vertical vs horizontal preference
|
||||||
|
- Prefer horizontal arrows for same-tier connections
|
||||||
|
- Prefer vertical arrows for cross-tier flows (consumer -> service -> external)
|
||||||
|
- Diagonal arrows only when routing around would add 3+ waypoints
|
||||||
|
|
||||||
|
### Label placement on arrows
|
||||||
|
- Arrow labels should sit in empty space, not over boxes
|
||||||
|
- For vertical arrows: place label to the left or right, offset 15px
|
||||||
|
- For horizontal arrows: place label above, offset 10px
|
||||||
|
|
||||||
|
## Example: Two Connected Boxes
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "type": "cameraUpdate", "width": 800, "height": 600, "x": 50, "y": 50 },
|
||||||
|
{ "type": "rectangle", "id": "b1", "x": 100, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#a5d8ff", "fillStyle": "solid", "label": { "text": "Start", "fontSize": 20 } },
|
||||||
|
{ "type": "rectangle", "id": "b2", "x": 450, "y": 100, "width": 200, "height": 100, "roundness": { "type": 3 }, "backgroundColor": "#b2f2bb", "fillStyle": "solid", "label": { "text": "End", "fontSize": 20 } },
|
||||||
|
{ "type": "arrow", "id": "a1", "x": 300, "y": 150, "width": 150, "height": 0, "points": [[0,0],[150,0]], "endArrowhead": "arrow", "startBinding": { "elementId": "b1", "fixedPoint": [1, 0.5] }, "endBinding": { "elementId": "b2", "fixedPoint": [0, 0.5] } }
|
||||||
|
]
|
||||||
|
```
|
||||||
2
plugins/compound-engineering/skills/excalidraw-png-export/scripts/.gitignore
vendored
Normal file
2
plugins/compound-engineering/skills/excalidraw-png-export/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.export-runtime/
|
||||||
|
.export-tmp/
|
||||||
171
plugins/compound-engineering/skills/excalidraw-png-export/scripts/convert.mjs
Executable file
171
plugins/compound-engineering/skills/excalidraw-png-export/scripts/convert.mjs
Executable file
@@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Convert raw Excalidraw MCP checkpoint JSON into a valid .excalidraw file.
|
||||||
|
* Filters pseudo-elements, adds required defaults, expands labels into bound text.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
// Canvas-based text measurement with graceful fallback to heuristic.
|
||||||
|
// Excalidraw renders with Virgil (hand-drawn font); system sans-serif
|
||||||
|
// is a reasonable proxy. The 1.1x multiplier accounts for Virgil being wider.
|
||||||
|
let measureText;
|
||||||
|
try {
|
||||||
|
const { createCanvas } = await import('canvas');
|
||||||
|
const cvs = createCanvas(1, 1);
|
||||||
|
const ctx = cvs.getContext('2d');
|
||||||
|
measureText = (text, fontSize) => {
|
||||||
|
ctx.font = `${fontSize}px sans-serif`;
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const widths = lines.map(line => ctx.measureText(line).width * 1.1);
|
||||||
|
return {
|
||||||
|
width: Math.max(...widths),
|
||||||
|
height: lines.length * (fontSize * 1.25),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.warn('WARN: canvas not available, using heuristic text sizing (install canvas for accurate measurement)');
|
||||||
|
measureText = (text, fontSize) => {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return {
|
||||||
|
width: Math.max(...lines.map(l => l.length)) * fontSize * 0.55,
|
||||||
|
height: lines.length * (fontSize + 4),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [,, inputFile, outputFile] = process.argv;
|
||||||
|
if (!inputFile || !outputFile) {
|
||||||
|
console.error('Usage: node convert.mjs <input.json> <output.excalidraw>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = JSON.parse(readFileSync(inputFile, 'utf8'));
|
||||||
|
const elements = raw.elements || raw;
|
||||||
|
|
||||||
|
let seed = 1000;
|
||||||
|
const nextSeed = () => seed++;
|
||||||
|
|
||||||
|
const processed = [];
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
if (['cameraUpdate', 'delete', 'restoreCheckpoint'].includes(el.type)) continue;
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
angle: 0,
|
||||||
|
roughness: 1,
|
||||||
|
opacity: el.opacity ?? 100,
|
||||||
|
groupIds: [],
|
||||||
|
seed: nextSeed(),
|
||||||
|
version: 1,
|
||||||
|
versionNonce: nextSeed(),
|
||||||
|
isDeleted: false,
|
||||||
|
boundElements: null,
|
||||||
|
link: null,
|
||||||
|
locked: false,
|
||||||
|
strokeColor: el.strokeColor || '#1e1e1e',
|
||||||
|
backgroundColor: el.backgroundColor || 'transparent',
|
||||||
|
fillStyle: el.fillStyle || 'solid',
|
||||||
|
strokeWidth: el.strokeWidth ?? 2,
|
||||||
|
strokeStyle: el.strokeStyle || 'solid',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (el.type === 'text') {
|
||||||
|
const fontSize = el.fontSize || 16;
|
||||||
|
const measured = measureText(el.text, fontSize);
|
||||||
|
processed.push({
|
||||||
|
...base,
|
||||||
|
type: 'text',
|
||||||
|
id: el.id,
|
||||||
|
x: el.x,
|
||||||
|
y: el.y,
|
||||||
|
width: measured.width,
|
||||||
|
height: measured.height,
|
||||||
|
text: el.text,
|
||||||
|
fontSize, fontFamily: 1,
|
||||||
|
textAlign: 'left',
|
||||||
|
verticalAlign: 'top',
|
||||||
|
baseline: fontSize,
|
||||||
|
containerId: null,
|
||||||
|
originalText: el.text,
|
||||||
|
});
|
||||||
|
} else if (el.type === 'arrow') {
|
||||||
|
const arrowEl = {
|
||||||
|
...base,
|
||||||
|
type: 'arrow',
|
||||||
|
id: el.id,
|
||||||
|
x: el.x,
|
||||||
|
y: el.y,
|
||||||
|
width: el.width || 0,
|
||||||
|
height: el.height || 0,
|
||||||
|
points: el.points || [[0, 0]],
|
||||||
|
startArrowhead: el.startArrowhead || null,
|
||||||
|
endArrowhead: el.endArrowhead ?? 'arrow',
|
||||||
|
startBinding: el.startBinding ? { ...el.startBinding, focus: 0, gap: 5 } : null,
|
||||||
|
endBinding: el.endBinding ? { ...el.endBinding, focus: 0, gap: 5 } : null,
|
||||||
|
roundness: { type: 2 },
|
||||||
|
boundElements: [],
|
||||||
|
};
|
||||||
|
processed.push(arrowEl);
|
||||||
|
|
||||||
|
if (el.label) {
|
||||||
|
const labelId = el.id + '_label';
|
||||||
|
const text = el.label.text || '';
|
||||||
|
const fontSize = el.label.fontSize || 14;
|
||||||
|
const { width: w, height: h } = measureText(text, fontSize);
|
||||||
|
const midPt = el.points[Math.floor(el.points.length / 2)] || [0, 0];
|
||||||
|
|
||||||
|
processed.push({
|
||||||
|
...base,
|
||||||
|
type: 'text', id: labelId,
|
||||||
|
x: el.x + midPt[0] - w / 2,
|
||||||
|
y: el.y + midPt[1] - h / 2 - 12,
|
||||||
|
width: w, height: h,
|
||||||
|
text, fontSize, fontFamily: 1,
|
||||||
|
textAlign: 'center', verticalAlign: 'middle',
|
||||||
|
baseline: fontSize, containerId: el.id, originalText: text,
|
||||||
|
strokeColor: el.strokeColor || '#1e1e1e',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
arrowEl.boundElements = [{ id: labelId, type: 'text' }];
|
||||||
|
}
|
||||||
|
} else if (['rectangle', 'ellipse', 'diamond'].includes(el.type)) {
|
||||||
|
const shapeEl = {
|
||||||
|
...base,
|
||||||
|
type: el.type, id: el.id,
|
||||||
|
x: el.x, y: el.y, width: el.width, height: el.height,
|
||||||
|
roundness: el.roundness || null,
|
||||||
|
boundElements: [],
|
||||||
|
};
|
||||||
|
processed.push(shapeEl);
|
||||||
|
|
||||||
|
if (el.label) {
|
||||||
|
const labelId = el.id + '_label';
|
||||||
|
const text = el.label.text || '';
|
||||||
|
const fontSize = el.label.fontSize || 16;
|
||||||
|
const { width: w, height: h } = measureText(text, fontSize);
|
||||||
|
|
||||||
|
processed.push({
|
||||||
|
...base,
|
||||||
|
type: 'text', id: labelId,
|
||||||
|
x: el.x + (el.width - w) / 2,
|
||||||
|
y: el.y + (el.height - h) / 2,
|
||||||
|
width: w, height: h,
|
||||||
|
text, fontSize, fontFamily: 1,
|
||||||
|
textAlign: 'center', verticalAlign: 'middle',
|
||||||
|
baseline: fontSize, containerId: el.id, originalText: text,
|
||||||
|
strokeColor: el.strokeColor || '#1e1e1e',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
});
|
||||||
|
shapeEl.boundElements = [{ id: labelId, type: 'text' }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFileSync(outputFile, JSON.stringify({
|
||||||
|
type: 'excalidraw', version: 2, source: 'claude-code',
|
||||||
|
elements: processed,
|
||||||
|
appState: { exportBackground: true, viewBackgroundColor: '#ffffff' },
|
||||||
|
files: {},
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
console.log(`Wrote ${processed.length} elements to ${outputFile}`);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: white; }
|
||||||
|
#root { width: 900px; height: 400px; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
window.EXCALIDRAW_ASSET_PATH = "https://esm.sh/@excalidraw/excalidraw/dist/prod/";
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"react": "https://esm.sh/react@18",
|
||||||
|
"react-dom": "https://esm.sh/react-dom@18",
|
||||||
|
"react-dom/client": "https://esm.sh/react-dom@18/client",
|
||||||
|
"react/jsx-runtime": "https://esm.sh/react@18/jsx-runtime",
|
||||||
|
"@excalidraw/excalidraw": "https://esm.sh/@excalidraw/excalidraw@0.18.0?external=react,react-dom"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module">
|
||||||
|
import { exportToBlob } from "@excalidraw/excalidraw";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const resp = await fetch("./diagram.excalidraw");
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
const validTypes = ["rectangle","ellipse","diamond","text","arrow","line","freedraw","image","frame"];
|
||||||
|
const elements = data.elements.filter(el => validTypes.includes(el.type));
|
||||||
|
|
||||||
|
const blob = await exportToBlob({
|
||||||
|
elements,
|
||||||
|
appState: {
|
||||||
|
exportBackground: true,
|
||||||
|
viewBackgroundColor: data.appState?.viewBackgroundColor || "#ffffff",
|
||||||
|
exportWithDarkMode: data.appState?.exportWithDarkMode || false,
|
||||||
|
},
|
||||||
|
files: data.files || {},
|
||||||
|
getDimensions: (w, h) => ({ width: w * 2, height: h * 2, scale: 2 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
window.__PNG_DATA__ = reader.result;
|
||||||
|
document.title = "READY";
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(e => {
|
||||||
|
console.error("EXPORT ERROR:", e);
|
||||||
|
document.title = "ERROR:" + e.message;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Export an Excalidraw JSON file to PNG using Playwright + the official Excalidraw library.
|
||||||
|
*
|
||||||
|
* Usage: node export_png.mjs <input.excalidraw> [output.png]
|
||||||
|
*
|
||||||
|
* All rendering happens locally. Diagram data never leaves the machine.
|
||||||
|
* The Excalidraw JS library is fetched from esm.sh CDN (code only, not user data).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRequire } from "module";
|
||||||
|
import { readFileSync, writeFileSync, copyFileSync } from "fs";
|
||||||
|
import { createServer } from "http";
|
||||||
|
import { join, extname, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const RUNTIME_DIR = join(__dirname, ".export-runtime");
|
||||||
|
const HTML_PATH = join(__dirname, "export.html");
|
||||||
|
|
||||||
|
// Resolve playwright from the runtime directory, not the script's location
|
||||||
|
const require = createRequire(join(RUNTIME_DIR, "node_modules", "playwright", "index.mjs"));
|
||||||
|
const { chromium } = await import(join(RUNTIME_DIR, "node_modules", "playwright", "index.mjs"));
|
||||||
|
|
||||||
|
const inputPath = process.argv[2];
|
||||||
|
if (!inputPath) {
|
||||||
|
console.error("Usage: node export_png.mjs <input.excalidraw> [output.png]");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputPath = process.argv[3] || inputPath.replace(/\.excalidraw$/, ".png");
|
||||||
|
|
||||||
|
// Set up a temp serving directory
|
||||||
|
const SERVE_DIR = join(__dirname, ".export-tmp");
|
||||||
|
const { mkdirSync, rmSync } = await import("fs");
|
||||||
|
mkdirSync(SERVE_DIR, { recursive: true });
|
||||||
|
copyFileSync(HTML_PATH, join(SERVE_DIR, "export.html"));
|
||||||
|
copyFileSync(inputPath, join(SERVE_DIR, "diagram.excalidraw"));
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
".html": "text/html",
|
||||||
|
".json": "application/json",
|
||||||
|
".excalidraw": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const file = join(SERVE_DIR, req.url === "/" ? "export.html" : req.url);
|
||||||
|
try {
|
||||||
|
const data = readFileSync(file);
|
||||||
|
res.writeHead(200, { "Content-Type": MIME[extname(file)] || "application/octet-stream" });
|
||||||
|
res.end(data);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end("Not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1", async () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
page.on("pageerror", err => console.error("Page error:", err.message));
|
||||||
|
|
||||||
|
await page.goto(`http://127.0.0.1:${port}`);
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.title.startsWith("READY") || document.title.startsWith("ERROR"),
|
||||||
|
{ timeout: 30000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = await page.title();
|
||||||
|
if (title.startsWith("ERROR")) {
|
||||||
|
console.error("Export failed:", title);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataUrl = await page.evaluate(() => window.__PNG_DATA__);
|
||||||
|
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||||
|
writeFileSync(outputPath, Buffer.from(base64, "base64"));
|
||||||
|
console.log(outputPath);
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
server.close();
|
||||||
|
rmSync(SERVE_DIR, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
37
plugins/compound-engineering/skills/excalidraw-png-export/scripts/setup.sh
Executable file
37
plugins/compound-engineering/skills/excalidraw-png-export/scripts/setup.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# First-time setup for excalidraw-png-export skill.
|
||||||
|
# Installs playwright and chromium headless into a dedicated directory.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
EXPORT_DIR="$SCRIPT_DIR/.export-runtime"
|
||||||
|
|
||||||
|
if [ -d "$EXPORT_DIR/node_modules/playwright" ]; then
|
||||||
|
echo "Runtime already installed at $EXPORT_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing excalidraw-png-export runtime..."
|
||||||
|
mkdir -p "$EXPORT_DIR"
|
||||||
|
cd "$EXPORT_DIR"
|
||||||
|
|
||||||
|
# Initialize package.json with ESM support
|
||||||
|
cat > package.json << 'PACKAGEEOF'
|
||||||
|
{
|
||||||
|
"name": "excalidraw-export-runtime",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
|
PACKAGEEOF
|
||||||
|
|
||||||
|
npm install playwright 2>&1
|
||||||
|
npx playwright install chromium 2>&1
|
||||||
|
|
||||||
|
# canvas provides accurate text measurement for convert.mjs.
|
||||||
|
# Requires Cairo native library: brew install pkg-config cairo pango libpng jpeg giflib librsvg
|
||||||
|
# Falls back to heuristic sizing if unavailable.
|
||||||
|
npm install canvas 2>&1 || echo "WARN: canvas install failed (missing Cairo?). Heuristic text sizing will be used."
|
||||||
|
|
||||||
|
echo "Setup complete. Runtime installed at $EXPORT_DIR"
|
||||||
173
plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs
Executable file
173
plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs
Executable file
@@ -0,0 +1,173 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Spatial validation for .excalidraw files.
|
||||||
|
* Checks text overflow, arrow-text collisions, and element overlap.
|
||||||
|
* Usage: node validate.mjs <input.excalidraw>
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
const MIN_PADDING = 15;
|
||||||
|
|
||||||
|
const inputFile = process.argv[2];
|
||||||
|
if (!inputFile) {
|
||||||
|
console.error('Usage: node validate.mjs <input.excalidraw>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(readFileSync(inputFile, 'utf8'));
|
||||||
|
const elements = data.elements || data;
|
||||||
|
|
||||||
|
// Build element map
|
||||||
|
const elMap = new Map();
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.isDeleted) continue;
|
||||||
|
elMap.set(el.id, el);
|
||||||
|
}
|
||||||
|
|
||||||
|
let warnings = 0;
|
||||||
|
let errors = 0;
|
||||||
|
const checked = elements.filter(el => !el.isDeleted).length;
|
||||||
|
|
||||||
|
// --- Check 1: Text overflow within containers ---
|
||||||
|
// Skip arrow-bound labels — arrows are lines, not spatial containers.
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.isDeleted || el.type !== 'text' || !el.containerId) continue;
|
||||||
|
const parent = elMap.get(el.containerId);
|
||||||
|
if (!parent || parent.type === 'arrow') continue;
|
||||||
|
|
||||||
|
const textRight = el.x + el.width;
|
||||||
|
const textBottom = el.y + el.height;
|
||||||
|
const parentRight = parent.x + parent.width;
|
||||||
|
const parentBottom = parent.y + parent.height;
|
||||||
|
|
||||||
|
const paddingLeft = el.x - parent.x;
|
||||||
|
const paddingRight = parentRight - textRight;
|
||||||
|
const paddingTop = el.y - parent.y;
|
||||||
|
const paddingBottom = parentBottom - textBottom;
|
||||||
|
|
||||||
|
const overflows = [];
|
||||||
|
if (paddingLeft < MIN_PADDING) overflows.push(`left=${paddingLeft.toFixed(1)}px (need ${MIN_PADDING}px)`);
|
||||||
|
if (paddingRight < MIN_PADDING) overflows.push(`right=${paddingRight.toFixed(1)}px (need ${MIN_PADDING}px)`);
|
||||||
|
if (paddingTop < MIN_PADDING) overflows.push(`top=${paddingTop.toFixed(1)}px (need ${MIN_PADDING}px)`);
|
||||||
|
if (paddingBottom < MIN_PADDING) overflows.push(`bottom=${paddingBottom.toFixed(1)}px (need ${MIN_PADDING}px)`);
|
||||||
|
|
||||||
|
if (overflows.length > 0) {
|
||||||
|
const label = (el.text || '').replace(/\n/g, '\\n');
|
||||||
|
const truncated = label.length > 40 ? label.slice(0, 37) + '...' : label;
|
||||||
|
console.log(`WARN: text "${truncated}" (id=${el.id}) tight/overflow in container (id=${el.containerId})`);
|
||||||
|
console.log(` text_bbox=[${el.x.toFixed(0)},${el.y.toFixed(0)}]->[${textRight.toFixed(0)},${textBottom.toFixed(0)}]`);
|
||||||
|
console.log(` container_bbox=[${parent.x.toFixed(0)},${parent.y.toFixed(0)}]->[${parentRight.toFixed(0)},${parentBottom.toFixed(0)}]`);
|
||||||
|
console.log(` insufficient padding: ${overflows.join(', ')}`);
|
||||||
|
console.log();
|
||||||
|
warnings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Check 2: Arrow-text collisions ---
|
||||||
|
|
||||||
|
/** Check if line segment (p1->p2) intersects axis-aligned rectangle. */
|
||||||
|
function segmentIntersectsRect(p1, p2, rect) {
|
||||||
|
// rect = {x, y, w, h} -> min/max
|
||||||
|
const rxMin = rect.x;
|
||||||
|
const rxMax = rect.x + rect.w;
|
||||||
|
const ryMin = rect.y;
|
||||||
|
const ryMax = rect.y + rect.h;
|
||||||
|
|
||||||
|
// Cohen-Sutherland-style clipping
|
||||||
|
let [x1, y1] = [p1[0], p1[1]];
|
||||||
|
let [x2, y2] = [p2[0], p2[1]];
|
||||||
|
|
||||||
|
function outcode(x, y) {
|
||||||
|
let code = 0;
|
||||||
|
if (x < rxMin) code |= 1;
|
||||||
|
else if (x > rxMax) code |= 2;
|
||||||
|
if (y < ryMin) code |= 4;
|
||||||
|
else if (y > ryMax) code |= 8;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
let code1 = outcode(x1, y1);
|
||||||
|
let code2 = outcode(x2, y2);
|
||||||
|
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
if (!(code1 | code2)) return true; // both inside
|
||||||
|
if (code1 & code2) return false; // both outside same side
|
||||||
|
|
||||||
|
const codeOut = code1 || code2;
|
||||||
|
let x, y;
|
||||||
|
if (codeOut & 8) { y = ryMax; x = x1 + (x2 - x1) * (ryMax - y1) / (y2 - y1); }
|
||||||
|
else if (codeOut & 4) { y = ryMin; x = x1 + (x2 - x1) * (ryMin - y1) / (y2 - y1); }
|
||||||
|
else if (codeOut & 2) { x = rxMax; y = y1 + (y2 - y1) * (rxMax - x1) / (x2 - x1); }
|
||||||
|
else { x = rxMin; y = y1 + (y2 - y1) * (rxMin - x1) / (x2 - x1); }
|
||||||
|
|
||||||
|
if (codeOut === code1) { x1 = x; y1 = y; code1 = outcode(x1, y1); }
|
||||||
|
else { x2 = x; y2 = y; code2 = outcode(x2, y2); }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect text bounding boxes (excluding arrow-bound labels for their own arrow)
|
||||||
|
const textBoxes = [];
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.isDeleted || el.type !== 'text') continue;
|
||||||
|
textBoxes.push({
|
||||||
|
id: el.id,
|
||||||
|
containerId: el.containerId,
|
||||||
|
text: (el.text || '').replace(/\n/g, '\\n'),
|
||||||
|
rect: { x: el.x, y: el.y, w: el.width, h: el.height },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const el of elements) {
|
||||||
|
if (el.isDeleted || el.type !== 'arrow') continue;
|
||||||
|
if (!el.points || el.points.length < 2) continue;
|
||||||
|
|
||||||
|
// Compute absolute points
|
||||||
|
const absPoints = el.points.map(p => [el.x + p[0], el.y + p[1]]);
|
||||||
|
|
||||||
|
for (const tb of textBoxes) {
|
||||||
|
// Skip this arrow's own label
|
||||||
|
if (tb.containerId === el.id) continue;
|
||||||
|
|
||||||
|
for (let i = 0; i < absPoints.length - 1; i++) {
|
||||||
|
if (segmentIntersectsRect(absPoints[i], absPoints[i + 1], tb.rect)) {
|
||||||
|
const truncated = tb.text.length > 30 ? tb.text.slice(0, 27) + '...' : tb.text;
|
||||||
|
const seg = `[${absPoints[i].map(n => n.toFixed(0)).join(',')}]->[${absPoints[i + 1].map(n => n.toFixed(0)).join(',')}]`;
|
||||||
|
console.log(`WARN: arrow (id=${el.id}) segment ${seg} crosses text "${truncated}" (id=${tb.id})`);
|
||||||
|
console.log(` text_bbox=[${tb.rect.x.toFixed(0)},${tb.rect.y.toFixed(0)}]->[${(tb.rect.x + tb.rect.w).toFixed(0)},${(tb.rect.y + tb.rect.h).toFixed(0)}]`);
|
||||||
|
console.log();
|
||||||
|
warnings++;
|
||||||
|
break; // one warning per arrow-text pair
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Check 3: Element overlap (non-child, same depth) ---
|
||||||
|
const topLevel = elements.filter(el =>
|
||||||
|
!el.isDeleted && !el.containerId && el.type !== 'text' && el.type !== 'arrow'
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < topLevel.length; i++) {
|
||||||
|
for (let j = i + 1; j < topLevel.length; j++) {
|
||||||
|
const a = topLevel[i];
|
||||||
|
const b = topLevel[j];
|
||||||
|
|
||||||
|
const aRight = a.x + a.width;
|
||||||
|
const aBottom = a.y + a.height;
|
||||||
|
const bRight = b.x + b.width;
|
||||||
|
const bBottom = b.y + b.height;
|
||||||
|
|
||||||
|
if (a.x < bRight && aRight > b.x && a.y < bBottom && aBottom > b.y) {
|
||||||
|
const overlapX = Math.min(aRight, bRight) - Math.max(a.x, b.x);
|
||||||
|
const overlapY = Math.min(aBottom, bBottom) - Math.max(a.y, b.y);
|
||||||
|
console.log(`WARN: overlap between (id=${a.id}) and (id=${b.id}): ${overlapX.toFixed(0)}x${overlapY.toFixed(0)}px`);
|
||||||
|
console.log();
|
||||||
|
warnings++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
console.log(`OK: ${checked} elements checked, ${warnings} warning(s), ${errors} error(s)`);
|
||||||
|
process.exit(warnings > 0 ? 1 : 0);
|
||||||
Reference in New Issue
Block a user