#!/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'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { createRequire } from 'module'; const __dirname = dirname(fileURLToPath(import.meta.url)); const runtimeRequire = createRequire(join(__dirname, '.export-runtime', 'package.json')); // 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 canvas = runtimeRequire('canvas'); const { createCanvas } = 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}`);