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:
2026-07-27 15:20:26 +03:00
co-authored by Claude Fable 5
parent 99f1fd07e3
commit 77655d1d97
72 changed files with 1089 additions and 1828 deletions
@@ -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(/&#xa;|<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,
};
}
+25
View File
@@ -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,
};
}
+164
View File
@@ -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,
};
}
+133
View File
@@ -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 };
}