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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* drawio-tools CLI dispatcher
|
||||
*
|
||||
* Usage:
|
||||
* drawio-tools --file <path-to-drawio> --action <action-name> [--page <index>]
|
||||
*
|
||||
* Actions:
|
||||
* summary — inventory of all shapes and edges for every page in the file
|
||||
* page-summary — inventory of shapes and edges for a single page (use --page, default 0)
|
||||
* page-hierarchy — containment tree of shapes (layers → subsystems → components)
|
||||
* page-connectors-summary — summary of all connectors with type, labels, waypoints
|
||||
* page-connectors-validation — detect connector-shape overlaps and connector crossings
|
||||
* page-labels-validation — detect empty, duplicate, or overly long shape labels
|
||||
* page-shape-bbox-validation — detect non-containment bounding box overlaps between shapes
|
||||
* page-orphans — find isolated shapes and dangling connectors
|
||||
* page-recommendations — bounding box analysis and page size recommendation
|
||||
* page-negative-space-summary — free horizontal corridors (negative space) per nesting level
|
||||
*
|
||||
* Output is always YAML to stdout. Exit 0 on success, 1 on error.
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ActionModule = {
|
||||
run: (filePath: string, pageIndex?: number) => Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
|
||||
"summary": () => import("../actions/summary/action.js"),
|
||||
"page-summary": () => import("../actions/page-summary/action.js"),
|
||||
"page-hierarchy": () => import("../actions/page-hierarchy/action.js"),
|
||||
"page-connectors-summary": () => import("../actions/page-connectors-summary/action.js"),
|
||||
"page-connectors-validation": () => import("../actions/page-connectors-validation/action.js"),
|
||||
"page-labels-validation": () => import("../actions/page-labels-validation/action.js"),
|
||||
"page-shape-bbox-validation": () => import("../actions/page-shape-bbox-validation/action.js"),
|
||||
"page-orphans": () => import("../actions/page-orphans/action.js"),
|
||||
"page-recommendations": () => import("../actions/page-recommendations/action.js"),
|
||||
"page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"),
|
||||
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"),
|
||||
"validate": () => import("../actions/validate/action.js"),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main(): Promise<void> {
|
||||
let values: { file?: string; action?: string; page?: string; help?: boolean };
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
file: { type: "string", short: "f" },
|
||||
action: { type: "string", short: "a" },
|
||||
page: { type: "string", short: "p" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
strict: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
printError(String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (values.help) {
|
||||
console.log(`drawio-tools --file <path> --action <action> [--page <index>]
|
||||
|
||||
Actions: ${Object.keys(ACTIONS).join(", ")}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!values.file) {
|
||||
printError("Missing required argument: --file");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!values.action) {
|
||||
printError("Missing required argument: --action");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actionName = values.action;
|
||||
const filePath = values.file;
|
||||
|
||||
// Parse optional --page argument (0-based index, default 0)
|
||||
let pageIndex = 0;
|
||||
if (values.page !== undefined) {
|
||||
pageIndex = parseInt(values.page, 10);
|
||||
if (isNaN(pageIndex) || pageIndex < 0) {
|
||||
printError(`--page must be a non-negative integer, got: ${values.page}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ACTIONS[actionName]) {
|
||||
printError(
|
||||
`Unknown action '${actionName}'. Available: ${Object.keys(ACTIONS).join(", ")}`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await ACTIONS[actionName]();
|
||||
const result = mod.run(filePath, pageIndex);
|
||||
process.stdout.write(
|
||||
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
|
||||
);
|
||||
} catch (err) {
|
||||
const errorOutput = {
|
||||
error: true,
|
||||
action: actionName,
|
||||
file: filePath,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
stack: err instanceof Error ? err.stack : undefined,
|
||||
};
|
||||
process.stdout.write(yaml.dump(errorOutput, { noRefs: true, sortKeys: false }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printError(msg: string): void {
|
||||
process.stderr.write(`Error: ${msg}\n`);
|
||||
process.stderr.write(
|
||||
`Usage: drawio-tools --file <path> --action <${Object.keys(ACTIONS).join("|")}> [--page <index>]\n`
|
||||
);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* connector-router — computes the actual routed path for draw.io connectors.
|
||||
*
|
||||
* Strategy: load the diagram XML into a real maxGraph Graph+GraphView instance
|
||||
* via graph-loader, which calls GraphView.validate() to compute all cell states
|
||||
* exactly as draw.io does at render time. Edge absolutePoints are taken directly
|
||||
* from those states — no synthetic geometry approximation.
|
||||
*
|
||||
* This is the only correct approach: draw.io's routing algorithms (especially
|
||||
* OrthConnector for edgeStyle=orthogonalEdgeStyle) depend on the full graph
|
||||
* state including parent container bounds, so they cannot be reproduced
|
||||
* accurately without loading the full model.
|
||||
*/
|
||||
|
||||
import { loadGraphStates } from "../maxgraph-loader/graph-loader.js";
|
||||
import type { Edge } from "../drawio-parser/parser.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RoutedPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of edgeId → routed points for ALL edges in the diagram.
|
||||
*
|
||||
* Call this once per diagram page and reuse the result for all edges.
|
||||
* Using `routeAllEdges` is significantly more efficient than calling
|
||||
* `routeEdge` in a loop because it runs `GraphView.validate()` only once.
|
||||
*
|
||||
* @param graphModelXml - Raw <mxGraphModel> XML for the page
|
||||
* @returns Map of edge id → array of canvas-absolute routed points
|
||||
*/
|
||||
export function routeAllEdges(graphModelXml: string): Map<string, RoutedPoint[]> {
|
||||
const { edgeRoutes } = loadGraphStates(graphModelXml);
|
||||
const result = new Map<string, RoutedPoint[]>();
|
||||
for (const [id, route] of edgeRoutes) {
|
||||
result.set(id, route.points);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routed points for a single edge from a pre-computed route map.
|
||||
*
|
||||
* For efficiency, pre-compute the map once using `routeAllEdges` and pass it here.
|
||||
* Falls back to a straight line between shape centers if the edge is not found.
|
||||
*
|
||||
* @param edge - The edge to route
|
||||
* @param routeMap - Pre-computed map from `routeAllEdges`
|
||||
* @param shapeMidpoints - Optional fallback: map of shapeId → {midX, midY}
|
||||
*/
|
||||
export function getEdgeRoute(
|
||||
edge: Edge,
|
||||
routeMap: Map<string, RoutedPoint[]>,
|
||||
shapeMidpoints?: Map<string, { midX: number; midY: number }>
|
||||
): RoutedPoint[] {
|
||||
const pts = routeMap.get(edge.id);
|
||||
if (pts && pts.length >= 2) return pts;
|
||||
|
||||
// Fallback: straight line using shape midpoints
|
||||
if (shapeMidpoints && edge.sourceId && edge.targetId) {
|
||||
const src = shapeMidpoints.get(edge.sourceId);
|
||||
const tgt = shapeMidpoints.get(edge.targetId);
|
||||
if (src && tgt) {
|
||||
return [{ x: src.midX, y: src.midY }, { x: tgt.midX, y: tgt.midY }];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Shared helper — build a plain serialisable summary object for a single ParsedPage.
|
||||
* Used by both diagram-page-summary and diagram-summary actions.
|
||||
*/
|
||||
|
||||
import type { ParsedPage } from "./parser.js";
|
||||
|
||||
export interface PageSummaryResult {
|
||||
pageIndex: number;
|
||||
pageName: string;
|
||||
page: { width: number; height: number };
|
||||
summary: {
|
||||
shapeCount: number;
|
||||
edgeCount: number;
|
||||
contentRight: number;
|
||||
contentBottom: number;
|
||||
};
|
||||
shapes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
midX: number;
|
||||
midY: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}>;
|
||||
edges: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
waypointCount: number;
|
||||
waypoints: Array<{ x: number; y: number }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function buildPageSummary(page: ParsedPage): PageSummaryResult {
|
||||
const shapes = Array.from(page.shapes.values()).map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
midX: Math.round(s.midX * 10) / 10,
|
||||
midY: Math.round(s.midY * 10) / 10,
|
||||
x2: Math.round(s.x2 * 10) / 10,
|
||||
y2: Math.round(s.y2 * 10) / 10,
|
||||
}));
|
||||
|
||||
const edges = page.edges.map((e) => ({
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
sourceId: e.sourceId,
|
||||
targetId: e.targetId,
|
||||
waypointCount: e.waypoints.length,
|
||||
waypoints: e.waypoints.map((w) => ({ x: w.x, y: w.y })),
|
||||
}));
|
||||
|
||||
const contentRight = shapes.reduce((max, s) => Math.max(max, s.x2), 0);
|
||||
const contentBottom = shapes.reduce((max, s) => Math.max(max, s.y2), 0);
|
||||
|
||||
return {
|
||||
pageIndex: page.pageIndex,
|
||||
pageName: page.pageName,
|
||||
page: { width: page.pageWidth, height: page.pageHeight },
|
||||
summary: {
|
||||
shapeCount: shapes.length,
|
||||
edgeCount: edges.length,
|
||||
contentRight: Math.round(contentRight * 10) / 10,
|
||||
contentBottom: Math.round(contentBottom * 10) / 10,
|
||||
},
|
||||
shapes,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* DrawioParser — parse a .drawio file into shapes and edges with absolute coordinates.
|
||||
*
|
||||
* Uses @maxgraph/core (the TypeScript successor to mxGraph) with a jsdom DOM polyfill
|
||||
* so that maxGraph can run in Node.js.
|
||||
*
|
||||
* Supports both:
|
||||
* - bare <mxGraphModel> XML files (single page)
|
||||
* - <mxfile><diagram>…</diagram></mxfile> wrappers (draw.io desktop format,
|
||||
* where each <diagram> body is base64+deflate encoded, multi-page supported)
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { inflateRaw } from "pako";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
GraphDataModel,
|
||||
ModelXmlSerializer,
|
||||
type Cell,
|
||||
type CellStyle,
|
||||
} from "@maxgraph/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// One-time jsdom DOM polyfill — maxGraph calls addEventListener, DOMParser, etc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
(function setupDomPolyfill() {
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
const w = dom.window as unknown as Record<string, unknown>;
|
||||
|
||||
// Assign globals that maxGraph expects in a browser environment
|
||||
(globalThis as Record<string, unknown>)["window"] = w;
|
||||
(globalThis as Record<string, unknown>)["document"] = dom.window.document;
|
||||
(globalThis as Record<string, unknown>)["DOMParser"] = dom.window.DOMParser;
|
||||
(globalThis as Record<string, unknown>)["XMLSerializer"] = dom.window.XMLSerializer;
|
||||
(globalThis as Record<string, unknown>)["Element"] = dom.window.Element;
|
||||
(globalThis as Record<string, unknown>)["HTMLElement"] = dom.window.HTMLElement;
|
||||
(globalThis as Record<string, unknown>)["Node"] = dom.window.Node;
|
||||
(globalThis as Record<string, unknown>)["Event"] = dom.window.Event;
|
||||
|
||||
// globalThis.navigator is non-configurable in some Node versions — use defineProperty
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Shape {
|
||||
id: string;
|
||||
label: string;
|
||||
style: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
parentId: string;
|
||||
/**
|
||||
* Header bar height for swimlane/container shapes (extracted from startSize= in style).
|
||||
* 0 for non-swimlane shapes.
|
||||
* The label of a swimlane lives in the band y..y+startSize.
|
||||
*/
|
||||
startSize: number;
|
||||
// computed
|
||||
readonly x2: number;
|
||||
readonly y2: number;
|
||||
readonly midX: number;
|
||||
readonly midY: number;
|
||||
}
|
||||
|
||||
export interface Waypoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Edge {
|
||||
id: string;
|
||||
label: string;
|
||||
style: string;
|
||||
sourceId: string | null;
|
||||
targetId: string | null;
|
||||
waypoints: Waypoint[];
|
||||
}
|
||||
|
||||
export interface ParsedPage {
|
||||
pageIndex: number; // 0-based
|
||||
pageName: string; // value of the name="" attribute on <diagram>, or "Page N"
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
shapes: Map<string, Shape>;
|
||||
edges: Edge[];
|
||||
/** Raw <mxGraphModel> XML string for this page — used by graph-loader for full routing */
|
||||
graphModelXml: string;
|
||||
}
|
||||
|
||||
/** Single-page result (first page) — kept for backward compatibility. */
|
||||
export interface ParsedDiagram extends ParsedPage {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decode <mxfile> base64+deflate diagram content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function decodeDiagramContent(content: string): string {
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), "base64");
|
||||
const decompressed = inflateRaw(decoded);
|
||||
const text = new TextDecoder("utf-8").decode(decompressed);
|
||||
return decodeURIComponent(text);
|
||||
} catch {
|
||||
// Already plain XML
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers — lightweight regex for attributes / element text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAttrFromTag(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] ?? "") : "";
|
||||
}
|
||||
|
||||
/** Convert a maxGraph CellStyle object back to a semicolon-delimited style string. */
|
||||
function cellStyleToString(style: CellStyle): string {
|
||||
const { baseStyleNames = [], ...props } = style;
|
||||
const parts: string[] = [...baseStyleNames];
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (v !== undefined && v !== null) {
|
||||
parts.push(`${k}=${String(v)}`);
|
||||
}
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function extractPageDimensions(graphModelXml: string): { pageWidth: number; pageHeight: number } {
|
||||
const m = /<mxGraphModel([^>]*)>/i.exec(graphModelXml);
|
||||
if (!m) return { pageWidth: 0, pageHeight: 0 };
|
||||
const attrs = m[1];
|
||||
const pw = getAttrFromTag(attrs, "pageWidth");
|
||||
const ph = getAttrFromTag(attrs, "pageHeight");
|
||||
return {
|
||||
pageWidth: pw ? parseFloat(pw) : 0,
|
||||
pageHeight: ph ? parseFloat(ph) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Shape from a maxGraph Cell (vertex)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Extract startSize from a CellStyle (swimlane header bar height). Default 0. */
|
||||
function extractStartSize(style: CellStyle): number {
|
||||
// maxGraph stores startSize as a numeric property on the style object
|
||||
const raw = (style as Record<string, unknown>)["startSize"];
|
||||
if (typeof raw === "number" && raw > 0) return raw;
|
||||
if (typeof raw === "string") {
|
||||
const n = parseFloat(raw);
|
||||
if (!isNaN(n) && n > 0) return n;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cellToShape(cell: Cell): Shape | null {
|
||||
const id = cell.id;
|
||||
if (!id || id === "0" || id === "1") return null;
|
||||
if (!cell.isVertex()) return null;
|
||||
|
||||
const geo = cell.getGeometry();
|
||||
if (!geo) return null;
|
||||
|
||||
// getOrigin() walks the parent chain and returns canvas-absolute top-left
|
||||
const origin = cell.getOrigin();
|
||||
const x = origin.x;
|
||||
const y = origin.y;
|
||||
const width = geo.width ?? 0;
|
||||
const height = geo.height ?? 0;
|
||||
const parentId = cell.parent?.id ?? "1";
|
||||
const startSize = extractStartSize(cell.style);
|
||||
|
||||
const shape: Shape = {
|
||||
id,
|
||||
label: (cell.value as string) ?? "",
|
||||
style: cellStyleToString(cell.style),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
parentId,
|
||||
startSize,
|
||||
get x2() { return this.x + this.width; },
|
||||
get y2() { return this.y + this.height; },
|
||||
get midX() { return this.x + this.width / 2; },
|
||||
get midY() { return this.y + this.height / 2; },
|
||||
};
|
||||
return shape;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build Edge from a maxGraph Cell (edge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cellToEdge(cell: Cell): Edge | null {
|
||||
const id = cell.id;
|
||||
if (!id) return null;
|
||||
if (!cell.isEdge()) return null;
|
||||
|
||||
const geo = cell.getGeometry();
|
||||
const waypoints: Waypoint[] = [];
|
||||
if (geo?.points) {
|
||||
for (const pt of geo.points) {
|
||||
waypoints.push({ x: pt.x, y: pt.y });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
label: (cell.value as string) ?? "",
|
||||
style: cellStyleToString(cell.style),
|
||||
sourceId: cell.source?.id ?? null,
|
||||
targetId: cell.target?.id ?? null,
|
||||
waypoints,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse a single <mxGraphModel> XML string into a ParsedPage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage {
|
||||
const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml);
|
||||
|
||||
const model = new GraphDataModel();
|
||||
new ModelXmlSerializer(model).import(graphModelXml);
|
||||
|
||||
const shapes = new Map<string, Shape>();
|
||||
const edges: Edge[] = [];
|
||||
|
||||
for (const cell of Object.values(model.cells ?? {})) {
|
||||
if (cell.isVertex()) {
|
||||
const shape = cellToShape(cell);
|
||||
if (shape) shapes.set(shape.id, shape);
|
||||
} else if (cell.isEdge()) {
|
||||
const edge = cellToEdge(cell);
|
||||
if (edge) edges.push(edge);
|
||||
}
|
||||
}
|
||||
|
||||
return { pageIndex, pageName, pageWidth, pageHeight, shapes, edges, graphModelXml };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extract all <diagram> blocks from an <mxfile> string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DiagramBlock {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
|
||||
const blocks: DiagramBlock[] = [];
|
||||
// Match each <diagram ...>...</diagram> element
|
||||
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(mxfileXml)) !== null) {
|
||||
const attrs = match[1];
|
||||
const content = match[2];
|
||||
const name = getAttrFromTag(attrs, "name") || "";
|
||||
blocks.push({ name, content: content.trim() });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse all pages in a .drawio file.
|
||||
* Returns one ParsedPage per diagram/tab found.
|
||||
*/
|
||||
export function parseAllPages(filePath: string): ParsedPage[] {
|
||||
const raw = readFileSync(filePath, "utf-8").trim();
|
||||
|
||||
if (/^<mxfile/i.test(raw)) {
|
||||
const blocks = extractDiagramBlocks(raw);
|
||||
if (blocks.length === 0) throw new Error("No <diagram> elements found in mxfile");
|
||||
|
||||
return blocks.map((block, i) => {
|
||||
const graphModelXml = decodeDiagramContent(block.content);
|
||||
const pageName = block.name || `Page ${i + 1}`;
|
||||
return parseGraphModelXml(graphModelXml, i, pageName);
|
||||
});
|
||||
}
|
||||
|
||||
if (/^<mxGraphModel/i.test(raw)) {
|
||||
return [parseGraphModelXml(raw, 0, "Page 1")];
|
||||
}
|
||||
|
||||
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the first page of a .drawio file.
|
||||
* Kept for backward compatibility with connectors-check and page-recommendations.
|
||||
*/
|
||||
export function parseDiagram(filePath: string): ParsedDiagram {
|
||||
return parseAllPages(filePath)[0];
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Shared hierarchy builder — used by page-hierarchy and page-hierarchy-full.
|
||||
*
|
||||
* Given a ParsedPage, builds a BFS depth map and a recursive containment tree.
|
||||
*/
|
||||
|
||||
import type { ParsedPage, Shape } from "../drawio-parser/parser.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HierarchyNode {
|
||||
id: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
children: HierarchyNode[];
|
||||
}
|
||||
|
||||
export interface HierarchyResult {
|
||||
tree: HierarchyNode[];
|
||||
allNodes: HierarchyNode[];
|
||||
depthMap: Map<string, number>; // shape id → depth
|
||||
childrenOf: Map<string, Shape[]>; // parentId → direct children (Shape objects)
|
||||
maxDepth: number;
|
||||
totalLevels: number; // maxDepth + 1 — total nesting levels
|
||||
depthCounts: Record<number, number>; // depth → count of shapes at that depth
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildTree(
|
||||
parentId: string,
|
||||
childrenMap: Map<string, string[]>,
|
||||
shapeLabels: Map<string, string>,
|
||||
depth: number
|
||||
): HierarchyNode[] {
|
||||
const ids = childrenMap.get(parentId) ?? [];
|
||||
return ids.map((id) => {
|
||||
const children = buildTree(id, childrenMap, shapeLabels, depth + 1);
|
||||
return {
|
||||
id,
|
||||
label: shapeLabels.get(id) ?? "",
|
||||
depth,
|
||||
childCount: children.length,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildHierarchy(page: ParsedPage): HierarchyResult {
|
||||
const allShapes = Array.from(page.shapes.values());
|
||||
|
||||
// Build parent → [children ids] map and label map (for tree)
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
const shapeLabels = new Map<string, string>();
|
||||
for (const s of allShapes) {
|
||||
shapeLabels.set(s.id, s.label);
|
||||
const parent = s.parentId ?? "1";
|
||||
if (!childrenMap.has(parent)) childrenMap.set(parent, []);
|
||||
childrenMap.get(parent)!.push(s.id);
|
||||
}
|
||||
|
||||
// Build parent → [children Shape] map (for geometry access)
|
||||
const childrenOf = new Map<string, Shape[]>();
|
||||
for (const s of allShapes) {
|
||||
const parent = s.parentId ?? "1";
|
||||
if (!childrenOf.has(parent)) childrenOf.set(parent, []);
|
||||
childrenOf.get(parent)!.push(s);
|
||||
}
|
||||
|
||||
// Build recursive tree
|
||||
const tree = buildTree("1", childrenMap, shapeLabels, 0);
|
||||
|
||||
// Flatten tree for stats + depth map
|
||||
const allNodes: HierarchyNode[] = [];
|
||||
const depthMap = new Map<string, number>();
|
||||
const stack = [...tree];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop()!;
|
||||
allNodes.push(node);
|
||||
depthMap.set(node.id, node.depth);
|
||||
stack.push(...node.children);
|
||||
}
|
||||
|
||||
const maxDepth = allNodes.reduce((m, n) => Math.max(m, n.depth), 0);
|
||||
const depthCounts: Record<number, number> = {};
|
||||
for (const n of allNodes) {
|
||||
depthCounts[n.depth] = (depthCounts[n.depth] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
tree,
|
||||
allNodes,
|
||||
depthMap,
|
||||
childrenOf,
|
||||
maxDepth,
|
||||
totalLevels: maxDepth + 1,
|
||||
depthCounts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* dom-polyfill.ts
|
||||
*
|
||||
* Sets up (or extends) the global DOM environment required by @maxgraph/core
|
||||
* when running in Node.js. Safe to import multiple times — idempotent.
|
||||
*
|
||||
* The parser (drawio-parser/parser.ts) already sets up a partial DOM polyfill
|
||||
* for the basic maxGraph model/serializer classes. This module extends it with
|
||||
* the additional globals needed by Graph+GraphView (rendering pipeline):
|
||||
* - location
|
||||
* - SVGElement, MouseEvent, KeyboardEvent, TouchEvent
|
||||
* - requestAnimationFrame / cancelAnimationFrame
|
||||
*
|
||||
* Must be imported before any maxGraph Graph/GraphView usage.
|
||||
*/
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
// Use the same DOM that may already be set up by parser.ts, or create a new one.
|
||||
// We detect by checking whether global.document already exists.
|
||||
let win: Record<string, unknown>;
|
||||
|
||||
if ((globalThis as Record<string, unknown>)["document"]) {
|
||||
// Parser already set up a DOM — re-use the existing window
|
||||
win = (globalThis as Record<string, unknown>)["window"] as Record<string, unknown>;
|
||||
} else {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body></body></html>',
|
||||
{ pretendToBeVisual: true }
|
||||
);
|
||||
win = dom.window as unknown as Record<string, unknown>;
|
||||
(globalThis as Record<string, unknown>)["document"] = win["document"];
|
||||
(globalThis as Record<string, unknown>)["window"] = win;
|
||||
(globalThis as Record<string, unknown>)["DOMParser"] = win["DOMParser"];
|
||||
(globalThis as Record<string, unknown>)["XMLSerializer"] = win["XMLSerializer"];
|
||||
(globalThis as Record<string, unknown>)["HTMLElement"] = win["HTMLElement"];
|
||||
(globalThis as Record<string, unknown>)["Element"] = win["Element"];
|
||||
(globalThis as Record<string, unknown>)["Node"] = win["Node"];
|
||||
(globalThis as Record<string, unknown>)["Event"] = win["Event"];
|
||||
try {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: win["navigator"],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} catch { /* already defined */ }
|
||||
}
|
||||
|
||||
// Add/overwrite globals needed specifically for Graph+GraphView rendering
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
if (!g["SVGElement"]) g["SVGElement"] = win["SVGElement"] ?? class SVGElement {};
|
||||
if (!g["MouseEvent"]) g["MouseEvent"] = win["MouseEvent"] ?? class MouseEvent {};
|
||||
if (!g["KeyboardEvent"]) g["KeyboardEvent"] = win["KeyboardEvent"] ?? class KeyboardEvent {};
|
||||
if (!g["TouchEvent"]) g["TouchEvent"] = win["TouchEvent"] ?? class TouchEvent {};
|
||||
if (!g["requestAnimationFrame"]) {
|
||||
g["requestAnimationFrame"] = (fn: () => void) => setTimeout(fn, 0);
|
||||
}
|
||||
if (!g["cancelAnimationFrame"]) {
|
||||
g["cancelAnimationFrame"] = clearTimeout;
|
||||
}
|
||||
// location is needed by UrlConverter (used when rendering image shapes)
|
||||
if (!g["location"]) {
|
||||
g["location"] = {
|
||||
protocol: "http:",
|
||||
host: "localhost",
|
||||
href: "http://localhost/",
|
||||
pathname: "/",
|
||||
};
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* graph-loader.ts
|
||||
*
|
||||
* Loads a draw.io diagram XML into a full maxGraph Graph+GraphView instance
|
||||
* and computes all cell states (including edge absolutePoints) exactly as
|
||||
* draw.io does at render time.
|
||||
*
|
||||
* This is the authoritative source for edge routing — do NOT hand-compute
|
||||
* orthogonal paths; use the routed points returned here instead.
|
||||
*
|
||||
* Usage:
|
||||
* import { loadGraphStates } from "./graph-loader.js";
|
||||
* const { edgePoints, vertexBounds } = loadGraphStates(graphModelXml);
|
||||
*/
|
||||
|
||||
// DOM polyfill MUST be imported first so globals are set before maxGraph loads
|
||||
import "./dom-polyfill.js";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { Graph, GraphDataModel, ModelXmlSerializer } from "@maxgraph/core";
|
||||
import type { Cell, CellState } from "@maxgraph/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface EdgeRoute {
|
||||
/** Canvas-absolute points for the full routed path (including endpoints) */
|
||||
points: Point[];
|
||||
}
|
||||
|
||||
export interface VertexBounds {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GraphStates {
|
||||
/** Map of edge cell ID → routed absolute points */
|
||||
edgeRoutes: Map<string, EdgeRoute>;
|
||||
/** Map of vertex cell ID → absolute bounding box */
|
||||
vertexBounds: Map<string, VertexBounds>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: create a fresh DOM container for each graph instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createContainer(): HTMLElement {
|
||||
const dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body><div id="g" style="width:4000px;height:4000px;"></div></body></html>',
|
||||
{ pretendToBeVisual: true }
|
||||
);
|
||||
return dom.window.document.getElementById("g") as HTMLElement;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load a single <mxGraphModel> XML string into a maxGraph Graph instance,
|
||||
* run GraphView.validate() to compute all cell states, and return the
|
||||
* resulting edge routes and vertex bounds.
|
||||
*
|
||||
* @param graphModelXml - Raw <mxGraphModel ...>...</mxGraphModel> XML string
|
||||
*/
|
||||
export function loadGraphStates(graphModelXml: string): GraphStates {
|
||||
const dataModel = new GraphDataModel();
|
||||
const serializer = new ModelXmlSerializer(dataModel);
|
||||
serializer.import(graphModelXml);
|
||||
|
||||
const container = createContainer();
|
||||
const graph = new Graph(container, dataModel);
|
||||
|
||||
// Compute all cell states (geometry, routing, labels…)
|
||||
graph.view.validate();
|
||||
|
||||
const states = graph.view.getStates() as Map<Cell, CellState>;
|
||||
|
||||
const edgeRoutes = new Map<string, EdgeRoute>();
|
||||
const vertexBounds = new Map<string, VertexBounds>();
|
||||
|
||||
for (const [cell, state] of states) {
|
||||
const id = cell.id;
|
||||
if (!id || id === "0" || id === "1") continue;
|
||||
|
||||
if (cell.isEdge()) {
|
||||
const pts = state.absolutePoints as Array<Point | null> | null;
|
||||
if (pts && pts.length >= 2) {
|
||||
const resolved: Point[] = pts
|
||||
.filter((p): p is Point => p != null)
|
||||
.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 }));
|
||||
edgeRoutes.set(id, { points: resolved });
|
||||
}
|
||||
} else if (cell.isVertex()) {
|
||||
vertexBounds.set(id, {
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { edgeRoutes, vertexBounds };
|
||||
}
|
||||
Reference in New Issue
Block a user