diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/SKILL.md b/plugins/compound-engineering/skills/excalidraw-png-export/SKILL.md new file mode 100644 index 0000000..4cc27ee --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/SKILL.md @@ -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 /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: "" }) +``` + +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: "" }) +``` + +### 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 /scripts/convert.mjs +``` + +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 /scripts/convert.mjs raw1.json diagram1.excalidraw +node /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: , +version: 1, versionNonce: , isDeleted: false, +boundElements: null, link: null, locked: false +``` + +Text elements also need: `fontFamily: 1, textAlign: "left", verticalAlign: "top", baseline: 14, containerId: null, originalText: ""` + +Bound text (labels on shapes/arrows) needs: `containerId: ""`, `textAlign: "center"`, `verticalAlign: "middle"`, and the parent needs `boundElements: [{"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 /scripts/.export-runtime && node /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 `. + +### Step 5.5: Validate and Iterate + +Run the validation script on the `.excalidraw` file to catch spatial issues: + +```bash +node /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. diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/references/excalidraw-element-format.md b/plugins/compound-engineering/skills/excalidraw-png-export/references/excalidraw-element-format.md new file mode 100644 index 0000000..cd5e7dc --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/references/excalidraw-element-format.md @@ -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] } } +] +``` diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/.gitignore b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/.gitignore new file mode 100644 index 0000000..6ade475 --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/.gitignore @@ -0,0 +1,2 @@ +.export-runtime/ +.export-tmp/ diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/convert.mjs b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/convert.mjs new file mode 100755 index 0000000..801b1d4 --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/convert.mjs @@ -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 '); + 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}`); diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export.html b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export.html new file mode 100644 index 0000000..cc4f0b9 --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export.html @@ -0,0 +1,61 @@ + + + + + + + + +
+ + + + diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export_png.mjs b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export_png.mjs new file mode 100755 index 0000000..99ce2d3 --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/export_png.mjs @@ -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 [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 [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 }); + } +}); diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/setup.sh b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/setup.sh new file mode 100755 index 0000000..3d7d0b2 --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/setup.sh @@ -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" diff --git a/plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs new file mode 100755 index 0000000..705bd7a --- /dev/null +++ b/plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs @@ -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 + */ +import { readFileSync } from 'fs'; + +const MIN_PADDING = 15; + +const inputFile = process.argv[2]; +if (!inputFile) { + console.error('Usage: node validate.mjs '); + 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);