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:
John Lamb
2026-02-26 17:19:14 -06:00
parent 36ae861046
commit f524c1b9d8
8 changed files with 824 additions and 0 deletions

View File

@@ -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.

View 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] } }
]
```

View File

@@ -0,0 +1,2 @@
.export-runtime/
.export-tmp/

View 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}`);

View File

@@ -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>

View File

@@ -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 });
}
});

View 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"

View 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);