#!/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);