Restructure to skill-manager conventions
- Move the TypeScript project under scripts/ (src, tsconfig, package.json, pnpm lockfile/workspace, canonical .gitignore); drop the npm package-lock - Add scripts/Taskfile.yml aggregator plus .scripts modules (loggers, base, cli) with build, run, and validate tasks - Move the six SKILL-*.md docs into references/ with kebab names and extract the connector-routing sections from SKILL.md into references/routing-best-practices.md (SKILL.md 666 -> ~310 lines) - Add license/metadata/compatibility frontmatter, an Available scripts section, and update all CLI paths in README and references skill-manager validate: 13/13 passed, 0 warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Action: page-connectors-summary
|
||||
*
|
||||
* Returns a summary of all connectors (edges) on a page:
|
||||
* - Total connector count
|
||||
* - Per-connector: id, label, source, target, waypoint count, type (directed/undirected/floating),
|
||||
* routed exit point (first routed coordinate), routed entry point (last routed coordinate)
|
||||
* - Aggregated stats: labelled vs unlabelled, with waypoints vs straight, floating (missing endpoint)
|
||||
* - Per-shape port summary: for each shape that is a source or target of 2+ connectors,
|
||||
* lists the actual exit/entry coordinates to help verify single-port rule compliance.
|
||||
*/
|
||||
|
||||
import { parseDiagram } from "../../services/drawio-parser/parser.js";
|
||||
import { routeAllEdges } from "../../services/connector-router/connector-router.js";
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { edges, shapes, graphModelXml } = parseDiagram(filePath);
|
||||
|
||||
// Get routed paths for all edges
|
||||
const routeMap = routeAllEdges(graphModelXml);
|
||||
|
||||
const connectors = edges.map((e) => {
|
||||
const hasSource = e.sourceId !== null && shapes.has(e.sourceId);
|
||||
const hasTarget = e.targetId !== null && shapes.has(e.targetId);
|
||||
|
||||
let connectorType: string;
|
||||
if (hasSource && hasTarget) {
|
||||
connectorType = "directed";
|
||||
} else if (!hasSource && !hasTarget) {
|
||||
connectorType = "floating";
|
||||
} else {
|
||||
connectorType = "partial"; // one endpoint missing
|
||||
}
|
||||
|
||||
const pts = routeMap.get(e.id) ?? [];
|
||||
const exitPt = pts.length >= 1 ? { x: Math.round(pts[0].x * 10) / 10, y: Math.round(pts[0].y * 10) / 10 } : null;
|
||||
const entryPt = pts.length >= 1 ? { x: Math.round(pts[pts.length - 1].x * 10) / 10, y: Math.round(pts[pts.length - 1].y * 10) / 10 } : null;
|
||||
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sourceId: e.sourceId,
|
||||
sourceLabel: e.sourceId ? (shapes.get(e.sourceId)?.label ?? null) : null,
|
||||
targetId: e.targetId,
|
||||
targetLabel: e.targetId ? (shapes.get(e.targetId)?.label ?? null) : null,
|
||||
waypointCount: e.waypoints.length,
|
||||
type: connectorType,
|
||||
exitPoint: exitPt,
|
||||
entryPoint: entryPt,
|
||||
};
|
||||
});
|
||||
|
||||
// Build per-shape port map — only for shapes with 2+ connectors on a side
|
||||
const exitsByShape = new Map<string, Array<{ edgeId: string; label: string; pt: { x: number; y: number } }>>();
|
||||
const entriesByShape = new Map<string, Array<{ edgeId: string; label: string; pt: { x: number; y: number } }>>();
|
||||
|
||||
for (const c of connectors) {
|
||||
if (c.sourceId && c.exitPoint) {
|
||||
const list = exitsByShape.get(c.sourceId) ?? [];
|
||||
list.push({ edgeId: c.id, label: c.label, pt: c.exitPoint });
|
||||
exitsByShape.set(c.sourceId, list);
|
||||
}
|
||||
if (c.targetId && c.entryPoint) {
|
||||
const list = entriesByShape.get(c.targetId) ?? [];
|
||||
list.push({ edgeId: c.id, label: c.label, pt: c.entryPoint });
|
||||
entriesByShape.set(c.targetId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const shapePorts: Record<string, unknown>[] = [];
|
||||
const allShapeIds = new Set([...exitsByShape.keys(), ...entriesByShape.keys()]);
|
||||
for (const shapeId of allShapeIds) {
|
||||
const exits = exitsByShape.get(shapeId) ?? [];
|
||||
const entries = entriesByShape.get(shapeId) ?? [];
|
||||
if (exits.length < 2 && entries.length < 2) continue; // only shapes with multiple connectors on a side
|
||||
shapePorts.push({
|
||||
shapeId,
|
||||
shapeLabel: shapes.get(shapeId)?.label ?? shapeId,
|
||||
exits,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
const labelled = connectors.filter((c) => c.label.trim() !== "").length;
|
||||
const withWaypts = connectors.filter((c) => c.waypointCount > 0).length;
|
||||
const floating = connectors.filter((c) => c.type === "floating").length;
|
||||
const partial = connectors.filter((c) => c.type === "partial").length;
|
||||
const directed = connectors.filter((c) => c.type === "directed").length;
|
||||
|
||||
return {
|
||||
action: "page-connectors-summary",
|
||||
file: filePath,
|
||||
summary: {
|
||||
total: edges.length,
|
||||
directed,
|
||||
partial,
|
||||
floating,
|
||||
labelled,
|
||||
unlabelled: edges.length - labelled,
|
||||
withWaypoints: withWaypts,
|
||||
straight: edges.length - withWaypts,
|
||||
},
|
||||
connectors,
|
||||
shapePorts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* Action: page-connectors-validation
|
||||
*
|
||||
* Checks a .drawio file for five categories of layout defects:
|
||||
* 1. Connector-shape overlaps — a connector segment passes through a shape
|
||||
* that is not its own source or target.
|
||||
* 2. Connector crossings — two connector segments intersect at an interior point.
|
||||
* 3. Single-port violations — multiple outgoing connectors from the same shape
|
||||
* exit at different coordinates, OR multiple incoming connectors to the same
|
||||
* shape enter at different coordinates.
|
||||
* 4. Corner-port violations — a connector's first routed point (exit) or last routed
|
||||
* point (entry) coincides with a corner of its source/target shape.
|
||||
* 5. Header-edge violations — a connector segment runs along (touches) the bottom
|
||||
* edge of a swimlane/container header bar. The bottom edge of a swimlane header
|
||||
* is the visual line at y + startSize. Connectors must not touch or cross this line
|
||||
* because it visually merges with the header border, making the diagram hard to read.
|
||||
*
|
||||
* NOTE on 'y' in YAML output:
|
||||
* js-yaml (YAML 1.1 mode) quotes bare `y` keys as `'y'` because `y` is a YAML 1.1
|
||||
* boolean alias. To avoid this, horizontal segment Y coordinates are stored under
|
||||
* the key `yCoord` instead of `y`.
|
||||
*/
|
||||
|
||||
import { parseDiagram, Shape, Edge } from "../../services/drawio-parser/parser.js";
|
||||
import { routeAllEdges, RoutedPoint } from "../../services/connector-router/connector-router.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tolerance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENDPOINT_TOL = 1; // px — grace at connector endpoints touching own source/target
|
||||
const HEADER_EDGE_TOL = 1; // px — how close a segment can be to the header-bottom line
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Segment types
|
||||
// NOTE: HSegment uses `yCoord` (not `y`) to avoid YAML 1.1 boolean quoting of `y`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HSegment { type: "H"; x1: number; x2: number; yCoord: number }
|
||||
interface VSegment { type: "V"; y1: number; y2: number; x: number }
|
||||
interface DSegment { type: "D"; x1: number; y1: number; x2: number; y2: number }
|
||||
type Segment = HSegment | VSegment | DSegment;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build segment list from routed points
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SEG_TOL = 0.5;
|
||||
|
||||
function pointsToSegments(pts: RoutedPoint[]): Segment[] {
|
||||
const segs: Segment[] = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const { x: x1, y: y1 } = pts[i];
|
||||
const { x: x2, y: y2 } = pts[i + 1];
|
||||
if (Math.abs(y1 - y2) <= SEG_TOL) {
|
||||
segs.push({ type: "H", x1, x2, yCoord: (y1 + y2) / 2 });
|
||||
} else if (Math.abs(x1 - x2) <= SEG_TOL) {
|
||||
segs.push({ type: "V", y1, y2, x: (x1 + x2) / 2 });
|
||||
} else {
|
||||
segs.push({ type: "D", x1, y1, x2, y2 });
|
||||
}
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
// Helper to get x1/x2/y1/y2 from any segment
|
||||
function segBounds(seg: Segment): { x1: number; y1: number; x2: number; y2: number } {
|
||||
if (seg.type === "H") return { x1: seg.x1, y1: seg.yCoord, x2: seg.x2, y2: seg.yCoord };
|
||||
if (seg.type === "V") return { x1: seg.x, y1: seg.y1, x2: seg.x, y2: seg.y2 };
|
||||
return { x1: seg.x1, y1: seg.y1, x2: seg.x2, y2: seg.y2 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Segment vs shape overlap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function segmentOverlapsShape(seg: Segment, shape: Shape, isEndpointSeg: boolean): boolean {
|
||||
const { x: sx1, y: sy1, x2: sx2, y2: sy2 } = shape;
|
||||
const tol = isEndpointSeg ? ENDPOINT_TOL : 0;
|
||||
|
||||
if (seg.type === "H") {
|
||||
const Y = seg.yCoord;
|
||||
const xA = Math.min(seg.x1, seg.x2);
|
||||
const xB = Math.max(seg.x1, seg.x2);
|
||||
return (sy1 + tol) < Y && Y < (sy2 - tol) &&
|
||||
Math.max(xA, sx1) < Math.min(xB, sx2);
|
||||
}
|
||||
|
||||
if (seg.type === "V") {
|
||||
const X = seg.x;
|
||||
const yA = Math.min(seg.y1, seg.y2);
|
||||
const yB = Math.max(seg.y1, seg.y2);
|
||||
return (sx1 + tol) < X && X < (sx2 - tol) &&
|
||||
Math.max(yA, sy1) < Math.min(yB, sy2);
|
||||
}
|
||||
|
||||
return liangBarskyIntersects(
|
||||
seg.x1, seg.y1, seg.x2, seg.y2,
|
||||
sx1 + tol, sy1 + tol, sx2 - tol, sy2 - tol
|
||||
);
|
||||
}
|
||||
|
||||
function liangBarskyIntersects(
|
||||
ax: number, ay: number, bx: number, by: number,
|
||||
sx1: number, sy1: number, sx2: number, sy2: number
|
||||
): boolean {
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const p = [-dx, dx, -dy, dy];
|
||||
const q = [ax - sx1, sx2 - ax, ay - sy1, sy2 - ay];
|
||||
let t0 = 0, t1 = 1;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (p[i] === 0) {
|
||||
if (q[i] < 0) return false;
|
||||
} else if (p[i] < 0) {
|
||||
t0 = Math.max(t0, q[i] / p[i]);
|
||||
} else {
|
||||
t1 = Math.min(t1, q[i] / p[i]);
|
||||
}
|
||||
if (t0 > t1) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Segment–segment crossing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function segmentsCross(a: Segment, b: Segment): [number, number] | null {
|
||||
const { x1, y1, x2, y2 } = segBounds(a);
|
||||
const { x1: x3, y1: y3, x2: x4, y2: y4 } = segBounds(b);
|
||||
|
||||
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
||||
if (Math.abs(denom) < 1e-9) return null;
|
||||
|
||||
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
||||
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
||||
|
||||
const eps = 0.001;
|
||||
if (t > eps && t < 1 - eps && u > eps && u < 1 - eps) {
|
||||
const ix = x1 + t * (x2 - x1);
|
||||
const iy = y1 + t * (y2 - y1);
|
||||
return [Math.round(ix * 10) / 10, Math.round(iy * 10) / 10];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header-edge overlap: does a segment touch the bottom edge of a swimlane header?
|
||||
//
|
||||
// A swimlane with startSize=S has its header in the band [shape.y .. shape.y+S].
|
||||
// The bottom edge of the header is the line at Y = shape.y + S.
|
||||
// A connector must NOT run along (be coincident with) this line.
|
||||
//
|
||||
// Detection: a horizontal segment at yCoord ≈ (shape.y + shape.startSize) that
|
||||
// overlaps the horizontal span of the shape is a violation.
|
||||
// A vertical segment that passes through this line (y range straddles it) is also flagged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function segmentTouchesHeaderEdge(seg: Segment, shape: Shape): boolean {
|
||||
if (shape.startSize <= 0) return false; // not a swimlane
|
||||
|
||||
const headerBottomY = shape.y + shape.startSize;
|
||||
const sx1 = shape.x;
|
||||
const sx2 = shape.x2;
|
||||
|
||||
if (seg.type === "H") {
|
||||
const Y = seg.yCoord;
|
||||
// Is this segment running along the header-bottom line?
|
||||
if (Math.abs(Y - headerBottomY) <= HEADER_EDGE_TOL) {
|
||||
const xA = Math.min(seg.x1, seg.x2);
|
||||
const xB = Math.max(seg.x1, seg.x2);
|
||||
// Does it overlap the horizontal span of the swimlane?
|
||||
return Math.max(xA, sx1) < Math.min(xB, sx2);
|
||||
}
|
||||
}
|
||||
|
||||
if (seg.type === "V") {
|
||||
const X = seg.x;
|
||||
const yA = Math.min(seg.y1, seg.y2);
|
||||
const yB = Math.max(seg.y1, seg.y2);
|
||||
// Does this vertical segment cross the header-bottom line while inside the swimlane's X span?
|
||||
if (yA < headerBottomY - HEADER_EDGE_TOL && yB > headerBottomY + HEADER_EDGE_TOL) {
|
||||
return (sx1 + HEADER_EDGE_TOL) < X && X < (sx2 - HEADER_EDGE_TOL);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { shapes, edges, graphModelXml } = parseDiagram(filePath);
|
||||
const routeMap = routeAllEdges(graphModelXml);
|
||||
|
||||
const issues: Record<string, unknown>[] = [];
|
||||
|
||||
const edgeSegList: Array<{ edge: Edge; segs: Segment[]; pts: RoutedPoint[] }> = [];
|
||||
for (const edge of edges) {
|
||||
const pts = routeMap.get(edge.id) ?? [];
|
||||
const segs = pointsToSegments(pts);
|
||||
edgeSegList.push({ edge, segs, pts });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Connector-shape overlaps
|
||||
// -------------------------------------------------------------------------
|
||||
for (const { edge, segs } of edgeSegList) {
|
||||
for (let i = 0; i < segs.length; i++) {
|
||||
const seg = segs[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === segs.length - 1;
|
||||
|
||||
for (const [shapeId, shape] of shapes) {
|
||||
if (isFirst && shapeId === edge.sourceId) continue;
|
||||
if (isLast && shapeId === edge.targetId) continue;
|
||||
|
||||
if (shapeId === edge.sourceId || shapeId === edge.targetId) {
|
||||
if (!segmentOverlapsShape(seg, shape, true)) continue;
|
||||
}
|
||||
|
||||
const isEndpointSeg = isFirst || isLast;
|
||||
if (segmentOverlapsShape(seg, shape, isEndpointSeg)) {
|
||||
issues.push({
|
||||
type: "connector_shape_overlap",
|
||||
edgeId: edge.id,
|
||||
edgeLabel: edge.label,
|
||||
shapeId,
|
||||
shapeLabel: shape.label,
|
||||
segment: seg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Connector crossings
|
||||
// -------------------------------------------------------------------------
|
||||
const seenPairs = new Set<string>();
|
||||
for (let i = 0; i < edgeSegList.length; i++) {
|
||||
const { edge: ea, segs: segsA } = edgeSegList[i];
|
||||
for (let j = i + 1; j < edgeSegList.length; j++) {
|
||||
const { edge: eb, segs: segsB } = edgeSegList[j];
|
||||
const pairKey = ea.id < eb.id ? `${ea.id}|${eb.id}` : `${eb.id}|${ea.id}`;
|
||||
if (seenPairs.has(pairKey)) continue;
|
||||
|
||||
outer:
|
||||
for (const sa of segsA) {
|
||||
for (const sb of segsB) {
|
||||
const pt = segmentsCross(sa, sb);
|
||||
if (pt) {
|
||||
seenPairs.add(pairKey);
|
||||
issues.push({
|
||||
type: "connector_crossing",
|
||||
edgeAId: ea.id,
|
||||
edgeBId: eb.id,
|
||||
point: { x: pt[0], yCoord: pt[1] },
|
||||
});
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Single-port violations
|
||||
// -------------------------------------------------------------------------
|
||||
const PORT_TOL = 2;
|
||||
|
||||
const exitsByShape = new Map<string, Array<{ edgeId: string; pt: RoutedPoint }>>();
|
||||
for (const { edge, pts } of edgeSegList) {
|
||||
if (edge.sourceId && pts.length >= 1) {
|
||||
const list = exitsByShape.get(edge.sourceId) ?? [];
|
||||
list.push({ edgeId: edge.id, pt: pts[0] });
|
||||
exitsByShape.set(edge.sourceId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const entriesByShape = new Map<string, Array<{ edgeId: string; pt: RoutedPoint }>>();
|
||||
for (const { edge, pts } of edgeSegList) {
|
||||
if (edge.targetId && pts.length >= 1) {
|
||||
const list = entriesByShape.get(edge.targetId) ?? [];
|
||||
list.push({ edgeId: edge.id, pt: pts[pts.length - 1] });
|
||||
entriesByShape.set(edge.targetId, list);
|
||||
}
|
||||
}
|
||||
|
||||
function pointsMatch(a: RoutedPoint, b: RoutedPoint): boolean {
|
||||
return Math.abs(a.x - b.x) <= PORT_TOL && Math.abs(a.y - b.y) <= PORT_TOL;
|
||||
}
|
||||
|
||||
function checkPortViolation(
|
||||
shapeId: string,
|
||||
portType: "exit" | "entry",
|
||||
entries: Array<{ edgeId: string; pt: RoutedPoint }>
|
||||
) {
|
||||
if (entries.length < 2) return;
|
||||
const ref = entries[0].pt;
|
||||
const offenders = entries.filter((e) => !pointsMatch(e.pt, ref));
|
||||
if (offenders.length > 0) {
|
||||
const shape = shapes.get(shapeId);
|
||||
issues.push({
|
||||
type: "single_port_violation",
|
||||
portType,
|
||||
shapeId,
|
||||
shapeLabel: shape?.label ?? shapeId,
|
||||
referencePoint: { x: Math.round(ref.x * 10) / 10, yCoord: Math.round(ref.y * 10) / 10 },
|
||||
referenceEdgeId: entries[0].edgeId,
|
||||
violatingEdges: offenders.map((e) => ({
|
||||
edgeId: e.edgeId,
|
||||
point: { x: Math.round(e.pt.x * 10) / 10, yCoord: Math.round(e.pt.y * 10) / 10 },
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [shapeId, list] of exitsByShape) {
|
||||
checkPortViolation(shapeId, "exit", list);
|
||||
}
|
||||
for (const [shapeId, list] of entriesByShape) {
|
||||
checkPortViolation(shapeId, "entry", list);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Corner-port violations
|
||||
// -------------------------------------------------------------------------
|
||||
const CORNER_TOL = 2;
|
||||
|
||||
function isShapeCorner(pt: RoutedPoint, shape: Shape): boolean {
|
||||
const nearX1 = Math.abs(pt.x - shape.x) <= CORNER_TOL;
|
||||
const nearX2 = Math.abs(pt.x - shape.x2) <= CORNER_TOL;
|
||||
const nearY1 = Math.abs(pt.y - shape.y) <= CORNER_TOL;
|
||||
const nearY2 = Math.abs(pt.y - shape.y2) <= CORNER_TOL;
|
||||
return (nearX1 || nearX2) && (nearY1 || nearY2);
|
||||
}
|
||||
|
||||
for (const { edge, pts } of edgeSegList) {
|
||||
if (pts.length === 0) continue;
|
||||
|
||||
if (edge.sourceId) {
|
||||
const shape = shapes.get(edge.sourceId);
|
||||
if (shape && isShapeCorner(pts[0], shape)) {
|
||||
issues.push({
|
||||
type: "corner_port_violation",
|
||||
portType: "exit",
|
||||
edgeId: edge.id,
|
||||
edgeLabel: edge.label,
|
||||
shapeId: edge.sourceId,
|
||||
shapeLabel: shape.label,
|
||||
point: { x: Math.round(pts[0].x * 10) / 10, yCoord: Math.round(pts[0].y * 10) / 10 },
|
||||
suggestion: "Move exit point to bottom-center, top-center, left-center, or right-center of the source shape",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (edge.targetId) {
|
||||
const shape = shapes.get(edge.targetId);
|
||||
const lastPt = pts[pts.length - 1];
|
||||
if (shape && isShapeCorner(lastPt, shape)) {
|
||||
issues.push({
|
||||
type: "corner_port_violation",
|
||||
portType: "entry",
|
||||
edgeId: edge.id,
|
||||
edgeLabel: edge.label,
|
||||
shapeId: edge.targetId,
|
||||
shapeLabel: shape.label,
|
||||
point: { x: Math.round(lastPt.x * 10) / 10, yCoord: Math.round(lastPt.y * 10) / 10 },
|
||||
suggestion: "Move entry point to bottom-center, top-center, left-center, or right-center of the target shape",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Header-edge violations
|
||||
//
|
||||
// A connector must not run along (be coincident with) the bottom edge of a
|
||||
// swimlane/container header bar. This line is at shape.y + shape.startSize.
|
||||
// Running along this line visually merges with the header border.
|
||||
// -------------------------------------------------------------------------
|
||||
for (const { edge, segs } of edgeSegList) {
|
||||
for (const seg of segs) {
|
||||
for (const [shapeId, shape] of shapes) {
|
||||
if (shape.startSize <= 0) continue; // skip non-swimlane shapes
|
||||
// Skip own source/target
|
||||
if (shapeId === edge.sourceId || shapeId === edge.targetId) continue;
|
||||
|
||||
if (segmentTouchesHeaderEdge(seg, shape)) {
|
||||
issues.push({
|
||||
type: "connector_header_edge_overlap",
|
||||
edgeId: edge.id,
|
||||
edgeLabel: edge.label,
|
||||
shapeId,
|
||||
shapeLabel: shape.label,
|
||||
headerBottomY: shape.y + shape.startSize,
|
||||
segment: seg,
|
||||
suggestion: `Reroute connector to avoid running along the header bottom line (y=${shape.y + shape.startSize}) of '${shape.label}'`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overlaps = issues.filter((i) => i["type"] === "connector_shape_overlap").length;
|
||||
const crossings = issues.filter((i) => i["type"] === "connector_crossing").length;
|
||||
const singlePortViolations = issues.filter((i) => i["type"] === "single_port_violation").length;
|
||||
const cornerPortViolations = issues.filter((i) => i["type"] === "corner_port_violation").length;
|
||||
const headerEdgeViolations = issues.filter((i) => i["type"] === "connector_header_edge_overlap").length;
|
||||
|
||||
return {
|
||||
action: "page-connectors-validation",
|
||||
file: filePath,
|
||||
summary: {
|
||||
connectorShapeOverlaps: overlaps,
|
||||
connectorCrossings: crossings,
|
||||
singlePortViolations,
|
||||
cornerPortViolations,
|
||||
headerEdgeViolations,
|
||||
totalIssues: issues.length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Action: page-hierarchy-full
|
||||
*
|
||||
* Extends page-hierarchy with full geometry for every shape at each nesting level.
|
||||
*
|
||||
* Reuses the shared hierarchy-builder service (same logic as page-hierarchy).
|
||||
*
|
||||
* "Levels" = containment depth levels in the shape hierarchy (diagramming term):
|
||||
* level 1 (depth 0) = root-level shapes (parentId === "1")
|
||||
* level 2 (depth 1) = shapes whose parent is at level 1
|
||||
* level N (depth N-1) = shapes nested N-1 levels deep
|
||||
*
|
||||
* Output structure:
|
||||
* summary:
|
||||
* levelsTotal — number of distinct nesting levels
|
||||
* shapesTotal — total shape count
|
||||
* shapesPerLevel — { levelNumber: count }
|
||||
* levels:
|
||||
* - number — 1-based nesting level (1 = root, 2 = children, …)
|
||||
* shapeCount
|
||||
* shapes:
|
||||
* - id, label, x, y, width, height, parentId
|
||||
*
|
||||
* IMPORTANT: Parser returns absolute canvas coordinates for all shapes.
|
||||
*/
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
|
||||
import type { Shape } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
interface ShapeEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
parentId: string;
|
||||
}
|
||||
|
||||
interface NestingLevel {
|
||||
number: number;
|
||||
shapeCount: number;
|
||||
shapes: ShapeEntry[];
|
||||
}
|
||||
|
||||
export function run(
|
||||
filePath: string,
|
||||
pageIndex: number = 0
|
||||
): Record<string, unknown> {
|
||||
const pages = parseAllPages(filePath);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) {
|
||||
return { error: true, message: `Page index ${pageIndex} not found` };
|
||||
}
|
||||
|
||||
// Reuse shared hierarchy builder
|
||||
const { depthMap, maxDepth, totalLevels } = buildHierarchy(page);
|
||||
|
||||
const allShapes = Array.from(page.shapes.values());
|
||||
|
||||
// Group shapes by depth
|
||||
const levelMap = new Map<number, Shape[]>();
|
||||
for (const s of allShapes) {
|
||||
const d = depthMap.get(s.id);
|
||||
if (d === undefined) continue;
|
||||
if (!levelMap.has(d)) levelMap.set(d, []);
|
||||
levelMap.get(d)!.push(s);
|
||||
}
|
||||
|
||||
const levels: NestingLevel[] = [];
|
||||
for (let d = 0; d <= maxDepth; d++) {
|
||||
const shapes = (levelMap.get(d) ?? []).sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
levels.push({
|
||||
number: d + 1,
|
||||
shapeCount: shapes.length,
|
||||
shapes: shapes.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label.replace(/\n/g, " / "),
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
parentId: s.parentId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
action: "page-hierarchy-full",
|
||||
file: filePath,
|
||||
pageIndex,
|
||||
summary: {
|
||||
levelsTotal: totalLevels,
|
||||
shapesTotal: allShapes.length,
|
||||
shapesPerLevel: Object.fromEntries(levels.map((l) => [l.number, l.shapeCount])),
|
||||
},
|
||||
levels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Action: page-hierarchy
|
||||
*
|
||||
* Reads the parentId of every shape and builds a recursive containment tree.
|
||||
*
|
||||
* Depth levels:
|
||||
* 0 — layers / top-level containers (direct children of the canvas root, cell id "1")
|
||||
* 1 — subsystems / groups inside a layer
|
||||
* 2+ — leaf components and nested elements
|
||||
*
|
||||
* Summary includes levelsTotal — the number of distinct nesting levels in the diagram.
|
||||
*/
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
|
||||
|
||||
export function run(filePath: string, pageIndex: number = 0): Record<string, unknown> {
|
||||
const pages = parseAllPages(filePath);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) {
|
||||
return { error: true, message: `Page index ${pageIndex} not found` };
|
||||
}
|
||||
|
||||
const { tree, totalLevels, depthCounts } = buildHierarchy(page);
|
||||
|
||||
return {
|
||||
action: "page-hierarchy",
|
||||
file: filePath,
|
||||
summary: {
|
||||
shapesTotal: page.shapes.size,
|
||||
levelsTotal: totalLevels,
|
||||
depthCounts,
|
||||
},
|
||||
tree,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Action: page-labels-validation
|
||||
*
|
||||
* Validates shape labels on a single page and reports three categories of issues:
|
||||
* 1. empty_label — shape has no label (or whitespace only)
|
||||
* 2. duplicate_label — two or more shapes share the same label
|
||||
* 3. long_label — label exceeds MAX_LABEL_LENGTH characters
|
||||
*
|
||||
* Edges are excluded (connectors often have no label by design).
|
||||
*/
|
||||
|
||||
import { parseDiagram } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
const MAX_LABEL_LENGTH = 80;
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { shapes } = parseDiagram(filePath);
|
||||
|
||||
const issues: Record<string, unknown>[] = [];
|
||||
|
||||
// Track label → [ids] for duplicate detection
|
||||
const labelIndex = new Map<string, string[]>();
|
||||
|
||||
for (const [id, shape] of shapes) {
|
||||
const label = shape.label?.trim() ?? "";
|
||||
|
||||
// 1. Empty label
|
||||
if (label === "") {
|
||||
issues.push({ type: "empty_label", shapeId: id });
|
||||
continue; // skip further checks for unlabelled shapes
|
||||
}
|
||||
|
||||
// 2. Long label
|
||||
if (label.length > MAX_LABEL_LENGTH) {
|
||||
issues.push({
|
||||
type: "long_label",
|
||||
shapeId: id,
|
||||
label,
|
||||
length: label.length,
|
||||
maxAllowed: MAX_LABEL_LENGTH,
|
||||
});
|
||||
}
|
||||
|
||||
// Accumulate for duplicate check
|
||||
if (!labelIndex.has(label)) labelIndex.set(label, []);
|
||||
labelIndex.get(label)!.push(id);
|
||||
}
|
||||
|
||||
// 3. Duplicate labels
|
||||
for (const [label, ids] of labelIndex) {
|
||||
if (ids.length > 1) {
|
||||
issues.push({
|
||||
type: "duplicate_label",
|
||||
label,
|
||||
shapeIds: ids,
|
||||
count: ids.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const empty = issues.filter((i) => i["type"] === "empty_label").length;
|
||||
const duplicate = issues.filter((i) => i["type"] === "duplicate_label").length;
|
||||
const long = issues.filter((i) => i["type"] === "long_label").length;
|
||||
|
||||
return {
|
||||
action: "page-labels-validation",
|
||||
file: filePath,
|
||||
config: { maxLabelLength: MAX_LABEL_LENGTH },
|
||||
summary: {
|
||||
emptyLabels: empty,
|
||||
duplicateLabels: duplicate,
|
||||
longLabels: long,
|
||||
totalIssues: issues.length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Action: page-negative-space-summary
|
||||
*
|
||||
* For each nesting level in the diagram, computes the "negative space" —
|
||||
* the free horizontal corridors (X ranges) not occupied by any shape.
|
||||
*
|
||||
* This is useful for connector routing: a vertical connector can pass through
|
||||
* a level without crossing shapes only if its X coordinate falls within one of
|
||||
* the free corridors at that level.
|
||||
*
|
||||
* Uses page-hierarchy-full logic (via hierarchy-builder) to enumerate all levels
|
||||
* and shapes per level, then uses maxGraph-computed absolute bounds (via
|
||||
* loadGraphStates / vertexBounds) for accurate positions.
|
||||
*
|
||||
* TEXT-AWARE NEGATIVE SPACE
|
||||
* ─────────────────────────
|
||||
* Shapes report both their bounding-box occupied range AND the estimated text
|
||||
* region within that box. For swimlane headers (and any shape whose text does
|
||||
* not fill the full width) the flanking areas inside the bbox are free space.
|
||||
*
|
||||
* Text-width estimation formula:
|
||||
* charWidth = fontSize × 0.6 (avg glyph width for proportional fonts)
|
||||
* rawWidth = charCount × charWidth
|
||||
* padding = fontSize × 1.0 (horizontal padding: ~0.5 em each side)
|
||||
* textWidth = rawWidth + padding
|
||||
*
|
||||
* fontStyle flags (draw.io): 1=bold(+10%), 2=italic(+5%), 4=underline(no effect)
|
||||
*
|
||||
* Centering:
|
||||
* textXMin = shapeCenterX − textWidth/2
|
||||
* textXMax = shapeCenterX + textWidth/2
|
||||
* (clamped to shape bbox)
|
||||
*
|
||||
* Output structure:
|
||||
* summary:
|
||||
* levelsTotal — number of distinct nesting levels
|
||||
* diagramXMin — leftmost X of diagram canvas (from shapes)
|
||||
* diagramXMax — rightmost X of diagram canvas (from shapes)
|
||||
* levels:
|
||||
* - number — 1-based nesting level (1 = root containers, 2 = children, …)
|
||||
* yMin — top of this level's Y band
|
||||
* yMax — bottom of this level's Y band
|
||||
* shapeCount
|
||||
* shapes:
|
||||
* - id, label, xMin, xMax, yMin, yMax,
|
||||
* textXMin, textXMax, textWidth ← estimated text rendering region
|
||||
* textFlankLeft ← free space left of text inside bbox
|
||||
* textFlankRight ← free space right of text inside bbox
|
||||
* rows: — shapes grouped by distinct Y row (shapes at same Y band)
|
||||
* - rowIndex
|
||||
* yMin, yMax
|
||||
* shapeCount
|
||||
* occupiedRanges: — merged X ranges occupied by shapes in this row
|
||||
* - { xMin, xMax }
|
||||
* freeCorridors: — X ranges NOT occupied in this row (negative space)
|
||||
* - { xMin, xMax, midX, width }
|
||||
* freeCorridorCount
|
||||
* textAwareFreeCorridors: — free corridors using text regions instead of full bboxes
|
||||
* - { xMin, xMax, midX, width }
|
||||
* occupiedRanges: — merged X ranges across ALL shapes in this level
|
||||
* - { xMin, xMax }
|
||||
* freeCorridors: — X ranges NOT occupied by any shape across entire level
|
||||
* - { xMin, xMax, midX, width }
|
||||
* freeCorridorCount
|
||||
*
|
||||
* IMPORTANT: Absolute canvas coordinates are used throughout (not relative to parent).
|
||||
*/
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
|
||||
import { buildHierarchy } from "../../services/hierarchy-builder/hierarchy-builder.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ShapeEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
fontSize: number;
|
||||
fontStyle: number;
|
||||
textXMin: number;
|
||||
textXMax: number;
|
||||
textWidth: number;
|
||||
textFlankLeft: number;
|
||||
textFlankRight: number;
|
||||
}
|
||||
|
||||
interface XRange {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
}
|
||||
|
||||
interface FreeCorridor {
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
midX: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
interface RowEntry {
|
||||
rowIndex: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
shapeCount: number;
|
||||
occupiedRanges: XRange[];
|
||||
freeCorridors: FreeCorridor[];
|
||||
freeCorridorCount: number;
|
||||
textAwareFreeCorridors: FreeCorridor[];
|
||||
}
|
||||
|
||||
interface LevelEntry {
|
||||
number: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
shapeCount: number;
|
||||
shapes: ShapeEntry[];
|
||||
rows: RowEntry[];
|
||||
occupiedRanges: XRange[];
|
||||
freeCorridors: FreeCorridor[];
|
||||
freeCorridorCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Estimate the rendered text width for a draw.io label.
|
||||
*
|
||||
* Formula:
|
||||
* charWidth = fontSize × 0.6 (avg glyph width for proportional fonts)
|
||||
* rawWidth = charCount × charWidth
|
||||
* padding = fontSize × 1.0 (horizontal padding ~0.5em each side)
|
||||
* textWidth = rawWidth + padding
|
||||
*
|
||||
* fontStyle flags: 1=bold (+10%), 2=italic (+5%)
|
||||
*
|
||||
* Returns the estimated width in diagram units (px).
|
||||
*/
|
||||
function estimateTextWidth(label: string, fontSize: number, fontStyle: number): number {
|
||||
const text = label.replace(/
|<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, "");
|
||||
const lines = text.split(/[\n\/]/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
// Use the longest line for width estimation
|
||||
const maxLen = Math.max(...lines.map((l) => l.length), 0);
|
||||
|
||||
let charWidth = fontSize * 0.6;
|
||||
if (fontStyle & 1) charWidth *= 1.10; // bold
|
||||
if (fontStyle & 2) charWidth *= 1.05; // italic
|
||||
|
||||
const rawWidth = maxLen * charWidth;
|
||||
const padding = fontSize * 1.0;
|
||||
return Math.ceil(rawWidth + padding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the estimated text X range within a shape bbox (centered alignment).
|
||||
* Returns { textXMin, textXMax, textWidth } clamped to [xMin, xMax].
|
||||
*/
|
||||
function textRegion(
|
||||
xMin: number,
|
||||
xMax: number,
|
||||
label: string,
|
||||
fontSize: number,
|
||||
fontStyle: number
|
||||
): { textXMin: number; textXMax: number; textWidth: number } {
|
||||
const tw = estimateTextWidth(label, fontSize, fontStyle);
|
||||
const centerX = (xMin + xMax) / 2;
|
||||
const textXMin = Math.max(xMin, Math.round(centerX - tw / 2));
|
||||
const textXMax = Math.min(xMax, Math.round(centerX + tw / 2));
|
||||
return { textXMin, textXMax, textWidth: textXMax - textXMin };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract fontSize and fontStyle from a draw.io style string.
|
||||
* Defaults: fontSize=11, fontStyle=0
|
||||
*/
|
||||
function parseTextStyle(style: string = ""): { fontSize: number; fontStyle: number } {
|
||||
const fsMatch = style.match(/fontSize=(\d+)/);
|
||||
const fstMatch = style.match(/fontStyle=(\d+)/);
|
||||
return {
|
||||
fontSize: fsMatch ? parseInt(fsMatch[1], 10) : 11,
|
||||
fontStyle: fstMatch ? parseInt(fstMatch[1], 10) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge overlapping or adjacent X ranges into a minimal set of disjoint ranges.
|
||||
* Input ranges do not need to be sorted.
|
||||
*/
|
||||
function mergeRanges(ranges: XRange[]): XRange[] {
|
||||
if (ranges.length === 0) return [];
|
||||
const sorted = [...ranges].sort((a, b) => a.xMin - b.xMin);
|
||||
const merged: XRange[] = [{ ...sorted[0] }];
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (sorted[i].xMin <= last.xMax) {
|
||||
last.xMax = Math.max(last.xMax, sorted[i].xMax);
|
||||
} else {
|
||||
merged.push({ ...sorted[i] });
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group shapes into Y-row clusters: shapes whose Y ranges overlap form one row.
|
||||
* Returns clusters sorted by yMin.
|
||||
*/
|
||||
function groupIntoRows(shapes: ShapeEntry[]): ShapeEntry[][] {
|
||||
if (shapes.length === 0) return [];
|
||||
const sorted = [...shapes].sort((a, b) => a.yMin - b.yMin);
|
||||
const rows: ShapeEntry[][] = [[sorted[0]]];
|
||||
let rowYMax = sorted[0].yMax;
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const s = sorted[i];
|
||||
if (s.yMin < rowYMax) {
|
||||
// Overlaps current row
|
||||
rows[rows.length - 1].push(s);
|
||||
rowYMax = Math.max(rowYMax, s.yMax);
|
||||
} else {
|
||||
// New row
|
||||
rows.push([s]);
|
||||
rowYMax = s.yMax;
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute free corridors between occupied ranges within [xMin, xMax].
|
||||
* Only corridors wider than minWidth are returned.
|
||||
*/
|
||||
function freeCorridors(
|
||||
occupied: XRange[],
|
||||
diagramXMin: number,
|
||||
diagramXMax: number,
|
||||
minWidth = 5
|
||||
): FreeCorridor[] {
|
||||
const merged = mergeRanges(occupied);
|
||||
const free: FreeCorridor[] = [];
|
||||
let cursor = diagramXMin;
|
||||
|
||||
for (const seg of merged) {
|
||||
if (seg.xMin > cursor + minWidth) {
|
||||
const w = seg.xMin - cursor;
|
||||
free.push({
|
||||
xMin: cursor,
|
||||
xMax: seg.xMin,
|
||||
midX: Math.round((cursor + seg.xMin) / 2),
|
||||
width: w,
|
||||
});
|
||||
}
|
||||
cursor = Math.max(cursor, seg.xMax);
|
||||
}
|
||||
|
||||
if (cursor < diagramXMax - minWidth) {
|
||||
const w = diagramXMax - cursor;
|
||||
free.push({
|
||||
xMin: cursor,
|
||||
xMax: diagramXMax,
|
||||
midX: Math.round((cursor + diagramXMax) / 2),
|
||||
width: w,
|
||||
});
|
||||
}
|
||||
|
||||
return free;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function run(
|
||||
filePath: string,
|
||||
pageIndex: number = 0
|
||||
): Record<string, unknown> {
|
||||
// --- Parse hierarchy (shapes with parentId / depth) ---
|
||||
const pages = parseAllPages(filePath);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) {
|
||||
return { error: true, message: `Page index ${pageIndex} not found` };
|
||||
}
|
||||
|
||||
const { depthMap, maxDepth, totalLevels } = buildHierarchy(page);
|
||||
|
||||
// --- Get maxGraph-computed absolute bounds for accuracy ---
|
||||
// page.graphModelXml is the raw <mxGraphModel> XML for this page
|
||||
const { vertexBounds } = loadGraphStates(page.graphModelXml);
|
||||
|
||||
// --- Compute diagram canvas extent from all vertex bounds ---
|
||||
let diagramXMin = Infinity;
|
||||
let diagramXMax = -Infinity;
|
||||
let diagramYMin = Infinity;
|
||||
let diagramYMax = -Infinity;
|
||||
for (const b of vertexBounds.values()) {
|
||||
diagramXMin = Math.min(diagramXMin, b.x);
|
||||
diagramXMax = Math.max(diagramXMax, b.x + b.width);
|
||||
diagramYMin = Math.min(diagramYMin, b.y);
|
||||
diagramYMax = Math.max(diagramYMax, b.y + b.height);
|
||||
}
|
||||
|
||||
// --- Group shapes by depth ---
|
||||
const levelMap = new Map<number, ShapeEntry[]>();
|
||||
for (const s of page.shapes.values()) {
|
||||
const d = depthMap.get(s.id);
|
||||
if (d === undefined) continue;
|
||||
|
||||
// Use maxGraph absolute bounds if available, fall back to parser coords
|
||||
const b = vertexBounds.get(s.id);
|
||||
const xMin = b ? b.x : s.x;
|
||||
const xMax = b ? b.x + b.width : s.x2;
|
||||
const yMin = b ? b.y : s.y;
|
||||
const yMax = b ? b.y + b.height : s.y2;
|
||||
|
||||
// Text-aware: estimate text region within shape bbox
|
||||
const { fontSize, fontStyle } = parseTextStyle(s.style ?? "");
|
||||
const labelClean = s.label.replace(/\n/g, " / ");
|
||||
const { textXMin, textXMax, textWidth } = textRegion(xMin, xMax, labelClean, fontSize, fontStyle);
|
||||
|
||||
if (!levelMap.has(d)) levelMap.set(d, []);
|
||||
levelMap.get(d)!.push({
|
||||
id: s.id,
|
||||
label: labelClean,
|
||||
xMin,
|
||||
xMax,
|
||||
yMin,
|
||||
yMax,
|
||||
fontSize,
|
||||
fontStyle,
|
||||
textXMin,
|
||||
textXMax,
|
||||
textWidth,
|
||||
textFlankLeft: textXMin - xMin,
|
||||
textFlankRight: xMax - textXMax,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Build per-level negative-space output ---
|
||||
const levels: LevelEntry[] = [];
|
||||
|
||||
for (let d = 0; d <= maxDepth; d++) {
|
||||
const shapes = (levelMap.get(d) ?? []).sort(
|
||||
(a, b) => a.yMin - b.yMin || a.xMin - b.xMin
|
||||
);
|
||||
|
||||
// Y band for this level = bounding box of all shapes at this depth
|
||||
const yMin = shapes.length > 0 ? Math.min(...shapes.map((s) => s.yMin)) : 0;
|
||||
const yMax = shapes.length > 0 ? Math.max(...shapes.map((s) => s.yMax)) : 0;
|
||||
|
||||
// Per-row negative space: group shapes into Y-row clusters
|
||||
const rowClusters = groupIntoRows(shapes);
|
||||
const rows: RowEntry[] = rowClusters.map((rowShapes, idx) => {
|
||||
const rowYMin = Math.min(...rowShapes.map((s) => s.yMin));
|
||||
const rowYMax = Math.max(...rowShapes.map((s) => s.yMax));
|
||||
const rowOccupied: XRange[] = rowShapes.map((s) => ({ xMin: s.xMin, xMax: s.xMax }));
|
||||
const rowOccupiedRanges = mergeRanges(rowOccupied);
|
||||
const rowFree = freeCorridors(rowOccupiedRanges, diagramXMin, diagramXMax);
|
||||
// Text-aware free corridors: use text region instead of full bbox
|
||||
const rowTextOccupied: XRange[] = rowShapes.map((s) => ({ xMin: s.textXMin, xMax: s.textXMax }));
|
||||
const rowTextOccupiedRanges = mergeRanges(rowTextOccupied);
|
||||
const rowTextFree = freeCorridors(rowTextOccupiedRanges, diagramXMin, diagramXMax);
|
||||
return {
|
||||
rowIndex: idx + 1,
|
||||
yMin: rowYMin,
|
||||
yMax: rowYMax,
|
||||
shapeCount: rowShapes.length,
|
||||
occupiedRanges: rowOccupiedRanges,
|
||||
freeCorridors: rowFree,
|
||||
freeCorridorCount: rowFree.length,
|
||||
textAwareFreeCorridors: rowTextFree,
|
||||
};
|
||||
});
|
||||
|
||||
// Occupied X ranges across all shapes in this level
|
||||
const occupied: XRange[] = shapes.map((s) => ({ xMin: s.xMin, xMax: s.xMax }));
|
||||
const occupiedRanges = mergeRanges(occupied);
|
||||
const free = freeCorridors(occupiedRanges, diagramXMin, diagramXMax);
|
||||
|
||||
levels.push({
|
||||
number: d + 1,
|
||||
yMin,
|
||||
yMax,
|
||||
shapeCount: shapes.length,
|
||||
shapes,
|
||||
rows,
|
||||
occupiedRanges,
|
||||
freeCorridors: free,
|
||||
freeCorridorCount: free.length,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
action: "page-negative-space-summary",
|
||||
file: filePath,
|
||||
pageIndex,
|
||||
summary: {
|
||||
levelsTotal: totalLevels,
|
||||
diagramXMin,
|
||||
diagramXMax,
|
||||
diagramYMin,
|
||||
diagramYMax,
|
||||
},
|
||||
levels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Action: page-orphans
|
||||
*
|
||||
* Finds two categories of disconnected elements:
|
||||
* 1. isolated_shape — a shape that has no edges connected to it at all
|
||||
* 2. dangling_connector — an edge that is missing its source and/or target shape
|
||||
*/
|
||||
|
||||
import { parseDiagram } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { shapes, edges } = parseDiagram(filePath);
|
||||
|
||||
// Build set of shape IDs that have at least one edge
|
||||
const connectedShapeIds = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (e.sourceId && shapes.has(e.sourceId)) connectedShapeIds.add(e.sourceId);
|
||||
if (e.targetId && shapes.has(e.targetId)) connectedShapeIds.add(e.targetId);
|
||||
}
|
||||
|
||||
const issues: Record<string, unknown>[] = [];
|
||||
|
||||
// 1. Isolated shapes
|
||||
for (const [id, shape] of shapes) {
|
||||
if (!connectedShapeIds.has(id)) {
|
||||
issues.push({
|
||||
type: "isolated_shape",
|
||||
shapeId: id,
|
||||
label: shape.label,
|
||||
x: shape.x,
|
||||
y: shape.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Dangling connectors
|
||||
for (const e of edges) {
|
||||
const missingSource = !e.sourceId || !shapes.has(e.sourceId);
|
||||
const missingTarget = !e.targetId || !shapes.has(e.targetId);
|
||||
|
||||
if (missingSource || missingTarget) {
|
||||
issues.push({
|
||||
type: "dangling_connector",
|
||||
edgeId: e.id,
|
||||
label: e.label,
|
||||
missingSource,
|
||||
missingTarget,
|
||||
sourceId: e.sourceId,
|
||||
targetId: e.targetId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isolated = issues.filter((i) => i["type"] === "isolated_shape").length;
|
||||
const dangling = issues.filter((i) => i["type"] === "dangling_connector").length;
|
||||
|
||||
return {
|
||||
action: "page-orphans",
|
||||
file: filePath,
|
||||
summary: {
|
||||
isolatedShapes: isolated,
|
||||
danglingConnectors: dangling,
|
||||
totalIssues: issues.length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Action: page-recommendations
|
||||
*
|
||||
* Analyses the bounding box of all shapes and recommends the smallest standard
|
||||
* landscape page size that accommodates the content with an 80px margin.
|
||||
*
|
||||
* Standard landscape sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339)
|
||||
*/
|
||||
|
||||
import { parseDiagram } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
const MARGIN = 80;
|
||||
|
||||
const STANDARD_SIZES: Array<{ name: string; width: number; height: number }> = [
|
||||
{ name: "A4", width: 1169, height: 827 },
|
||||
{ name: "A3", width: 1654, height: 1169 },
|
||||
{ name: "A2", width: 2339, height: 1654 },
|
||||
{ name: "A1", width: 3307, height: 2339 },
|
||||
];
|
||||
|
||||
function recommendPage(
|
||||
requiredW: number,
|
||||
requiredH: number
|
||||
): { name: string; width: number; height: number } {
|
||||
for (const size of STANDARD_SIZES) {
|
||||
if (size.width >= requiredW && size.height >= requiredH) return size;
|
||||
}
|
||||
// Custom — round up to nearest 10
|
||||
return {
|
||||
name: "custom",
|
||||
width: Math.ceil(requiredW / 10) * 10,
|
||||
height: Math.ceil(requiredH / 10) * 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { pageWidth, pageHeight, shapes } = parseDiagram(filePath);
|
||||
|
||||
let bbox: Record<string, number>;
|
||||
let requiredW: number;
|
||||
let requiredH: number;
|
||||
|
||||
if (shapes.size === 0) {
|
||||
bbox = { xMin: 0, yMin: 0, xMax: 0, yMax: 0, width: 0, height: 0 };
|
||||
requiredW = MARGIN;
|
||||
requiredH = MARGIN;
|
||||
} else {
|
||||
const allShapes = Array.from(shapes.values());
|
||||
const xMin = Math.min(...allShapes.map((s) => s.x));
|
||||
const yMin = Math.min(...allShapes.map((s) => s.y));
|
||||
const xMax = Math.max(...allShapes.map((s) => s.x2));
|
||||
const yMax = Math.max(...allShapes.map((s) => s.y2));
|
||||
|
||||
bbox = {
|
||||
xMin: Math.round(xMin * 10) / 10,
|
||||
yMin: Math.round(yMin * 10) / 10,
|
||||
xMax: Math.round(xMax * 10) / 10,
|
||||
yMax: Math.round(yMax * 10) / 10,
|
||||
width: Math.round((xMax - xMin) * 10) / 10,
|
||||
height: Math.round((yMax - yMin) * 10) / 10,
|
||||
};
|
||||
requiredW = xMax + MARGIN;
|
||||
requiredH = yMax + MARGIN;
|
||||
}
|
||||
|
||||
const recommended = recommendPage(requiredW, requiredH);
|
||||
|
||||
const orientation =
|
||||
pageWidth > pageHeight ? "landscape" :
|
||||
pageWidth < pageHeight ? "portrait" :
|
||||
"square";
|
||||
|
||||
const xMax = bbox["xMax"] as number;
|
||||
const yMax = bbox["yMax"] as number;
|
||||
const contentFits = pageWidth >= xMax && pageHeight >= yMax;
|
||||
const hasMargin = (pageWidth - xMax) >= MARGIN && (pageHeight - yMax) >= MARGIN;
|
||||
|
||||
return {
|
||||
action: "page-recommendations",
|
||||
file: filePath,
|
||||
currentPage: {
|
||||
width: pageWidth,
|
||||
height: pageHeight,
|
||||
orientation,
|
||||
},
|
||||
contentBbox: bbox,
|
||||
required: {
|
||||
width: Math.round(requiredW * 10) / 10,
|
||||
height: Math.round(requiredH * 10) / 10,
|
||||
},
|
||||
recommendedPage: recommended,
|
||||
checks: {
|
||||
contentFitsCurrentPage: contentFits,
|
||||
hasAdequateMargin: hasMargin,
|
||||
isLandscape: orientation === "landscape",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Action: page-shape-bbox-validation
|
||||
*
|
||||
* Detects shapes whose bounding boxes overlap each other.
|
||||
* Parent-child containment is intentional and is NOT flagged.
|
||||
*
|
||||
* Two shapes overlap when their axis-aligned bounding boxes intersect
|
||||
* with more than TOL pixels of penetration on both axes.
|
||||
*/
|
||||
|
||||
import { parseDiagram, Shape } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
const TOL = 2; // px — minimum penetration depth to flag as an overlap
|
||||
|
||||
function bboxOverlaps(a: Shape, b: Shape): boolean {
|
||||
// Axis-aligned overlap with tolerance
|
||||
return (
|
||||
a.x + TOL < b.x2 &&
|
||||
a.x2 - TOL > b.x &&
|
||||
a.y + TOL < b.y2 &&
|
||||
a.y2 - TOL > b.y
|
||||
);
|
||||
}
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { shapes } = parseDiagram(filePath);
|
||||
|
||||
// Build ancestor set for a shape (to skip parent-child pairs)
|
||||
function getAncestors(id: string): Set<string> {
|
||||
const ancestors = new Set<string>();
|
||||
let current = shapes.get(id);
|
||||
while (current && current.parentId && current.parentId !== "1") {
|
||||
ancestors.add(current.parentId);
|
||||
current = shapes.get(current.parentId);
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
const shapeList = Array.from(shapes.values());
|
||||
const issues: Record<string, unknown>[] = [];
|
||||
const seenPairs = new Set<string>();
|
||||
|
||||
for (let i = 0; i < shapeList.length; i++) {
|
||||
const a = shapeList[i];
|
||||
const aAncestors = getAncestors(a.id);
|
||||
|
||||
for (let j = i + 1; j < shapeList.length; j++) {
|
||||
const b = shapeList[j];
|
||||
|
||||
// Skip parent-child containment in either direction
|
||||
if (aAncestors.has(b.id)) continue;
|
||||
if (b.parentId === a.id || a.parentId === b.id) continue;
|
||||
|
||||
const pairKey = a.id < b.id ? `${a.id}|${b.id}` : `${b.id}|${a.id}`;
|
||||
if (seenPairs.has(pairKey)) continue;
|
||||
|
||||
if (bboxOverlaps(a, b)) {
|
||||
seenPairs.add(pairKey);
|
||||
issues.push({
|
||||
type: "bbox_overlap",
|
||||
shapeAId: a.id,
|
||||
shapeALabel: a.label,
|
||||
shapeBId: b.id,
|
||||
shapeBLabel: b.label,
|
||||
overlapX: Math.round((Math.min(a.x2, b.x2) - Math.max(a.x, b.x)) * 10) / 10,
|
||||
overlapY: Math.round((Math.min(a.y2, b.y2) - Math.max(a.y, b.y)) * 10) / 10,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "page-shape-bbox-validation",
|
||||
file: filePath,
|
||||
summary: {
|
||||
totalShapes: shapes.size,
|
||||
overlappingPairs: issues.length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Action: page-summary
|
||||
*
|
||||
* Parses a single page (tab) of a .drawio file and returns a YAML inventory
|
||||
* of all shapes and edges on that page, including bounding boxes, mid-points,
|
||||
* and waypoint counts.
|
||||
*
|
||||
* Use --page <index> (0-based) to select a page. Defaults to page 0.
|
||||
*/
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { buildPageSummary } from "../../services/drawio-parser/page-summary.js";
|
||||
|
||||
export function run(filePath: string, pageIndex = 0): Record<string, unknown> {
|
||||
const allPages = parseAllPages(filePath);
|
||||
|
||||
if (pageIndex < 0 || pageIndex >= allPages.length) {
|
||||
throw new Error(
|
||||
`Page index ${pageIndex} is out of range. File has ${allPages.length} page(s) (0–${allPages.length - 1}).`
|
||||
);
|
||||
}
|
||||
|
||||
const pageSummary = buildPageSummary(allPages[pageIndex]);
|
||||
|
||||
return {
|
||||
action: "page-summary",
|
||||
file: filePath,
|
||||
pageCount: allPages.length,
|
||||
...pageSummary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Action: summary
|
||||
*
|
||||
* Parses all pages (tabs) in a .drawio file and returns a YAML inventory of
|
||||
* shapes and edges for every page, including bounding boxes, mid-points, and
|
||||
* waypoint counts.
|
||||
*
|
||||
* Delegates per-page building to the shared buildPageSummary helper,
|
||||
* which is also used by the diagram-page-summary action.
|
||||
*/
|
||||
|
||||
import { parseAllPages } from "../../services/drawio-parser/parser.js";
|
||||
import { buildPageSummary } from "../../services/drawio-parser/page-summary.js";
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const allPages = parseAllPages(filePath);
|
||||
const pages = allPages.map((page) => buildPageSummary(page));
|
||||
|
||||
return {
|
||||
action: "summary",
|
||||
file: filePath,
|
||||
pageCount: pages.length,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Action: validate
|
||||
*
|
||||
* Validates that a draw.io diagram compiles correctly against the maxGraph library.
|
||||
*
|
||||
* For each page in the file this action:
|
||||
* 1. Checks that the page XML is well-formed (parseable by DOMParser in text/xml mode)
|
||||
* 2. Loads the page into a full maxGraph Graph instance via loadGraphStates()
|
||||
* (which calls graph.view.validate() — the same rendering pass draw.io performs)
|
||||
* 3. Reports the number of vertices and edges successfully resolved
|
||||
*
|
||||
* A page is considered INVALID if:
|
||||
* - The XML contains a parseerror element (malformed XML)
|
||||
* - loadGraphStates() throws an exception
|
||||
* - The resulting graph has 0 vertices AND 0 edges (import silently failed)
|
||||
*
|
||||
* Exit criteria (summary.valid):
|
||||
* true — all pages pass all three checks
|
||||
* false — at least one page fails
|
||||
*
|
||||
* Common causes of failure:
|
||||
* - HTML comments (<!-- -->) inside the mxGraphModel body: strict XML parsers
|
||||
* reject them when they appear between sibling elements in certain positions
|
||||
* - Stray/unmatched closing tags (e.g. orphaned </mxGeometry> or </mxCell>)
|
||||
* - Unescaped special characters in attribute values
|
||||
* - Base64/deflate encoding errors in the <diagram> body
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { inflateRaw } from "pako";
|
||||
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAttr(tagStr: string, attr: string): string {
|
||||
const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i");
|
||||
const m = re.exec(tagStr);
|
||||
return m ? (m[1] ?? m[2] ?? "") : "";
|
||||
}
|
||||
|
||||
function decodeDiagramContent(content: string): string {
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), "base64");
|
||||
const decompressed = inflateRaw(decoded);
|
||||
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
|
||||
} catch {
|
||||
return content; // already plain XML
|
||||
}
|
||||
}
|
||||
|
||||
interface PageResult {
|
||||
pageIndex: number;
|
||||
pageName: string;
|
||||
valid: boolean;
|
||||
xmlParseError: string | null;
|
||||
vertices: number;
|
||||
edges: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// XML well-formedness check using jsdom DOMParser (strict text/xml mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function checkXmlWellFormed(xml: string): string | null {
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
const parser = new dom.window.DOMParser();
|
||||
const doc = parser.parseFromString(xml, "text/xml");
|
||||
const err = doc.querySelector("parsererror");
|
||||
if (err) {
|
||||
// Return first line of error message
|
||||
return (err.textContent ?? "unknown parse error").split("\n")[0].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const raw = readFileSync(filePath, "utf-8").trim();
|
||||
|
||||
// Extract pages (support both mxfile wrapper and bare mxGraphModel)
|
||||
const pages: Array<{ name: string; xml: string }> = [];
|
||||
|
||||
if (/^<mxfile/i.test(raw)) {
|
||||
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
let idx = 0;
|
||||
while ((match = re.exec(raw)) !== null) {
|
||||
const name = getAttr(match[1], "name") || `Page ${idx + 1}`;
|
||||
const xml = decodeDiagramContent(match[2].trim());
|
||||
pages.push({ name, xml });
|
||||
idx++;
|
||||
}
|
||||
if (pages.length === 0) {
|
||||
throw new Error("No <diagram> elements found in mxfile");
|
||||
}
|
||||
} else if (/^<mxGraphModel/i.test(raw)) {
|
||||
pages.push({ name: "Page 1", xml: raw });
|
||||
} else {
|
||||
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
|
||||
}
|
||||
|
||||
const results: PageResult[] = [];
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const { name, xml } = pages[i];
|
||||
const result: PageResult = {
|
||||
pageIndex: i,
|
||||
pageName: name,
|
||||
valid: false,
|
||||
xmlParseError: null,
|
||||
vertices: 0,
|
||||
edges: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Step 1: XML well-formedness
|
||||
const parseError = checkXmlWellFormed(xml);
|
||||
if (parseError) {
|
||||
result.xmlParseError = parseError;
|
||||
result.error = `XML parse error: ${parseError}`;
|
||||
results.push(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: maxGraph compile (graph.view.validate())
|
||||
try {
|
||||
const { edgeRoutes, vertexBounds } = loadGraphStates(xml);
|
||||
result.vertices = vertexBounds.size;
|
||||
result.edges = edgeRoutes.size;
|
||||
|
||||
// Step 3: sanity check — a non-empty diagram must have at least some cells
|
||||
if (result.vertices === 0 && result.edges === 0) {
|
||||
result.error =
|
||||
"maxGraph loaded 0 vertices and 0 edges — XML may be structurally empty or silently failed to import";
|
||||
} else {
|
||||
result.valid = true;
|
||||
}
|
||||
} catch (err) {
|
||||
result.error = `maxGraph load error: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
const allValid = results.every((r) => r.valid);
|
||||
|
||||
return {
|
||||
action: "validate",
|
||||
file: filePath,
|
||||
summary: {
|
||||
pages: results.length,
|
||||
valid: allValid,
|
||||
invalidPages: results.filter((r) => !r.valid).length,
|
||||
},
|
||||
pages: results,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user