diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c8f1a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# NodeJs +dist +node_modules diff --git a/README.md b/README.md index 3ae734e..44f5e65 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,139 @@ # drawio-main -Agent Skill for creating and validating draw.io diagrams via layout rules and a validation CLI. + +CLI tools for programmatic analysis and verification of `.drawio` files. + +Built with TypeScript using [`@maxgraph/core`](https://github.com/maxGraph/maxGraph) — the official TypeScript successor to mxGraph (the library draw.io is built on) — for accurate XML parsing and absolute coordinate resolution. + +## Stack + +| Concern | Choice | +|---|---| +| Language | TypeScript (ESM, `NodeNext`) | +| Package manager | pnpm | +| XML / model parsing | `@maxgraph/core` v0.23 | +| Node.js DOM polyfill | `jsdom` | +| `` decoding | `pako` (base64 + deflate) | +| Output | YAML (via `js-yaml`) | + +## Install + +```bash +pnpm install +``` + +## Build + +```bash +pnpm run build +# compiled output → dist/ +``` + +## Usage + +```bash +node dist/cli/commands.js --file diagram.drawio --action [--page ] + +# or via tsx (no build step) +pnpm dev --file diagram.drawio --action +``` + +Output is always YAML to stdout. + +## Actions + +### Inventory + +#### `summary` + +Parses **all pages (tabs)** in the `.drawio` file and returns a full inventory of shapes and edges per page. Delegates per-page data to the same logic as `page-summary`. + +**Output:** `pageCount`, `pages[]` (each page has the same structure as `page-summary`) + +--- + +#### `page-summary` + +Parses a **single page**. Use `--page ` (0-based, default `0`). + +**Output:** `pageCount`, `pageIndex`, `pageName`, `page` (width/height), `summary` (shapeCount, edgeCount, contentRight, contentBottom), `shapes[]`, `edges[]` + +--- + +#### `page-hierarchy` + +Reads `parentId` from each shape and builds a **recursive containment tree**: +- Depth 0 — layers / top-level containers (direct children of the canvas) +- Depth 1 — subsystems / groups inside a layer +- Depth 2+ — leaf components and nested elements + +**Output:** `summary` (totalShapes, maxDepth, depthCounts), `tree[]` (recursive: id, label, depth, childCount, children[]) + +--- + +#### `page-connectors-summary` + +Returns a summary of all **connectors (edges)** on the page, classified by type and connectivity. + +Connector types: `directed` (both endpoints connected), `partial` (one missing), `floating` (both missing) + +**Output:** `summary` (total, directed, partial, floating, labelled, unlabelled, withWaypoints, straight), `connectors[]` (id, label, sourceId, sourceLabel, targetId, targetLabel, waypointCount, type) + +--- + +### Validation + +#### `page-connectors-validation` + +Detects two categories of layout defects on the first page: +1. **Connector-shape overlaps** — a connector segment passes through a shape it is not connected to +2. **Connector crossings** — two connectors intersect at an interior point + +Uses 2 px tolerance. **Output:** `summary` (connectorShapeOverlaps, connectorCrossings, totalIssues), `issues[]` + +--- + +#### `page-labels-validation` + +Validates shape labels and flags: +1. **empty_label** — shape has no label (whitespace counts as empty) +2. **duplicate_label** — two or more shapes share the same label +3. **long_label** — label exceeds 80 characters + +Edges/connectors are excluded. **Output:** `config` (maxLabelLength), `summary` (emptyLabels, duplicateLabels, longLabels, totalIssues), `issues[]` + +--- + +#### `page-shape-bbox-validation` + +Detects shapes whose **bounding boxes overlap** each other. Parent-child containment is intentional and is **not** flagged. Uses 2 px tolerance. + +**Output:** `summary` (totalShapes, overlappingPairs), `issues[]` (shapeAId, shapeALabel, shapeBId, shapeBLabel, overlapX, overlapY) + +--- + +#### `page-orphans` + +Finds disconnected elements: +1. **isolated_shape** — shape with no edges connected to it +2. **dangling_connector** — edge missing its source and/or target shape + +**Output:** `summary` (isolatedShapes, danglingConnectors, totalIssues), `issues[]` + +--- + +### Layout + +#### `page-recommendations` + +Computes the bounding box of all shapes and recommends the smallest standard landscape page size that fits with an 80 px margin (first page). + +Standard sizes: A4 (1169×827), A3 (1654×1169), A2 (2339×1654), A1 (3307×2339). Falls back to custom rounded size. + +**Output:** `currentPage` (width, height, orientation), `contentBbox`, `required` (min size needed), `recommendedPage` (name, width, height), `checks` (contentFitsCurrentPage, hasAdequateMargin, isLandscape) + +--- + +## Supported file formats + +- Bare `` XML files +- `…` wrappers (draw.io desktop format, base64+deflate encoded, multi-page supported) diff --git a/SKILL-CAPABILITIES.md b/SKILL-CAPABILITIES.md new file mode 100644 index 0000000..11e4333 --- /dev/null +++ b/SKILL-CAPABILITIES.md @@ -0,0 +1,59 @@ +# drawio-main — Capabilities + +This file lists all capabilities an agent can use from this skill. + +--- + +## Capability 1 — Diagram generation + +Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. + +See [SKILL.md](./SKILL.md) for the generation workflow, XML format, page sizes, element shapes, and well-formedness rules. +See [SKILL-RULES-LAYOUT.md](./SKILL-RULES-LAYOUT.md) for mandatory connector and layout rules. + +--- + +## Capability 2 — Diagram analysis (drawio-tools CLI) + +A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files. +Entry point: `node dist/cli/commands.js` (run from `.skills/drawio-main/`). + +### Usage + +```bash +node dist/cli/commands.js --file --action [--page ] +# short flags: -f, -a, -p, -h +``` + +`--page` selects the diagram tab (0-based, default 0). Ignored by `summary` (processes all pages). +Always prints YAML to stdout. Exit code `0` on success, `1` on error. + +### Actions + +#### Inventory + +| Action | Scope | Description | +|---|---|---| +| `summary` | All pages | Full inventory of shapes + edges for every page/tab | +| `page-summary` | Single page (`--page`) | Full inventory of shapes + edges for one page | +| `page-hierarchy` | Page 0 | Recursive containment tree with totalLevels (nesting depth count) | +| `page-hierarchy-full` | Page 0 | Same as page-hierarchy + full geometry (x, y, width, height) per shape at each nesting level | +| `page-connectors-summary` | Page 0 | Per-connector details: type, label, waypoints, source/target names | + +#### Validation + +| Action | Scope | Description | +|---|---|---| +| `validate` | All pages | **Mandatory final gate** — XML well-formedness + maxGraph compile + sanity check (vertices/edges > 0). Run before finishing any diagram work | +| `page-connectors-validation` | Page 0 | Connector-shape overlaps + connector crossings | +| `page-labels-validation` | Page 0 | Empty labels, duplicate labels, labels > 80 chars | +| `page-shape-bbox-validation` | Page 0 | Non-containment bounding box overlaps between shapes | +| `page-orphans` | Page 0 | Isolated shapes (no edges) + dangling connectors (missing endpoints) | + +#### Layout + +| Action | Scope | Description | +|---|---|---| +| `page-recommendations` | Page 0 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin | + +> For install/build instructions, source structure, and how to add new actions, see [SKILL-MAINTENANCE.md](./SKILL-MAINTENANCE.md). diff --git a/SKILL-MAINTENANCE.md b/SKILL-MAINTENANCE.md new file mode 100644 index 0000000..a258ed4 --- /dev/null +++ b/SKILL-MAINTENANCE.md @@ -0,0 +1,175 @@ +# drawio-main — Maintenance Guide + +This document is for **developers** maintaining or extending the `drawio-tools` CLI. +For agent usage instructions, see [SKILL.md](./SKILL.md). + +--- + +## Tech stack + +| Layer | Library / Tool | Version | +|---|---|---| +| Language | TypeScript ESM (`NodeNext`) | — | +| Package manager | pnpm | v11.9+ | +| XML model | `@maxgraph/core` | v0.23 | +| DOM polyfill | `jsdom` | — | +| Deflate decode | `pako` | — | +| YAML output | `js-yaml` | — | +| Build | `tsc` | — | +| Dev runner | `tsx` | — | + +`pnpm-workspace.yaml` must include: + +```yaml +allowBuilds: + esbuild: true +``` + +--- + +## Install to `.agents/skills` + +Always remove the previous installation first, then do a fresh copy and install: + +```bash +# 1. Remove previous installation +rm -rf .agents/skills/drawio-main + +# 2. Copy the full skill directory (source of truth) +cp -r CogArch-drawio-2-cogarch/.skills/drawio-main .agents/skills/drawio-main + +# 3. Install dependencies in the new location +cd .agents/skills/drawio-main && pnpm install +``` + +Verify the CLI works after installation: + +```bash +node .agents/skills/drawio-main/dist/cli/commands.js -f -a summary +``` + +--- + +## Run the CLI + +Single entry point — always builds first, then runs: + +```bash +cd CogArch-drawio-2-cogarch/.skills/drawio-main +pnpm run cli -f -a +``` + +Example: + +```bash +pnpm run cli -f ../../000-input/01-marketplace-system-context.drawio -a page-recommendations +``` + +> Note: pass arguments directly after `cli` — do **not** use `--` separator. + +--- + +## Build (development only) + +Build only (no run): + +```bash +cd CogArch-drawio-2-cogarch/.skills/drawio-main +pnpm run build # tsc → dist/ +``` + +--- + +## Source structure + +``` +src/ +├── cli/ +│ └── commands.ts # parseArgs dispatcher → dynamic action imports +├── services/ +│ ├── drawio-parser/ +│ │ ├── parser.ts # parseAllPages() / parseDiagram() — Shape, Edge, ParsedPage +│ │ └── page-summary.ts # buildPageSummary() — shared per-page serialisation helper +│ └── hierarchy-builder/ +│ └── hierarchy-builder.ts # buildHierarchy() — shared BFS depth map + containment tree +└── actions/ + ├── summary/ # all pages inventory + ├── page-summary/ # single page inventory (uses --page) + ├── page-hierarchy/ # containment tree from parentId + ├── page-connectors-summary/ # connector stats + ├── page-connectors-validation/ # overlap + crossing detection + ├── page-labels-validation/ # label quality checks + ├── page-shape-bbox-validation/ # bounding box overlap detection + ├── page-orphans/ # isolated shapes + dangling connectors + ├── page-recommendations/ # page size recommendation + ├── page-hierarchy-full/ # nesting levels with full shape geometry (x, y, width, height) + └── validate/ # MANDATORY final gate — XML well-formedness + maxGraph compile + sanity check +``` + +Each action exports `run(filePath: string, pageIndex?: number): Record`. + +### Key modules + +**`src/services/drawio-parser/parser.ts`** +- `parseAllPages(filePath)` — parses all `` tabs in an mxfile, returns `ParsedPage[]` +- `parseDiagram(filePath)` — backward-compat wrapper, returns first page only +- Multi-page support: regex extracts all `` blocks; each decoded separately (base64 + pako `inflateRaw`) +- Uses `@maxgraph/core` `ModelXmlSerializer` + `GraphDataModel` with a `jsdom` DOM polyfill + +**`src/services/drawio-parser/page-summary.ts`** +- `buildPageSummary(page: ParsedPage): PageSummaryResult` — shared helper used by both `summary` and `page-summary` actions + +**`src/services/hierarchy-builder/hierarchy-builder.ts`** +- `buildHierarchy(page: ParsedPage): HierarchyResult` — shared BFS depth map + containment tree +- Returns: `tree`, `allNodes`, `depthMap`, `childrenOf`, `maxDepth`, `totalLevels` (= maxDepth + 1), `depthCounts` +- Used by `page-hierarchy` (tree output) and `page-hierarchy-full` (geometry per nesting level) + +**`src/cli/commands.ts`** +- `parseArgs` dispatcher → dynamic imports of action modules +- Supports `--file` / `-f`, `--action` / `-a`, `--page` / `-p` (0-based, default 0), `--help` / `-h` +- All output serialised to YAML on stdout; exit `0` success, `1` error + +--- + +## Adding a new action + +1. Create `src/actions//action.ts` exporting: + ```ts + export async function run(filePath: string, pageIndex?: number): Promise> + ``` +2. Register it in `src/cli/commands.ts` under `ACTIONS`: + ```ts + "my-action": () => import("../actions/my-action/action.js"), + ``` +3. Use `parseDiagram(filePath)` (first page) or `parseAllPages(filePath)` (all pages) from the parser +4. Optionally import `buildPageSummary(page)` from `page-summary.ts` for standard shape/edge serialisation +5. Return a plain object — the CLI serialises it to YAML automatically +6. Run `pnpm run build` to compile and verify no TypeScript errors + +### Naming convention + +Actions follow `{object}-{action}` naming: + +- `page-*` — operates on a single diagram page (uses `--page`, default 0) +- `summary` — operates on all pages + +--- + +## CLI action registration map + +```typescript +const ACTIONS: Record Promise> = { + "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"), +}; +``` diff --git a/SKILL-NEGATIVE-SPACE-DIAGRAM.md b/SKILL-NEGATIVE-SPACE-DIAGRAM.md new file mode 100644 index 0000000..d13639d --- /dev/null +++ b/SKILL-NEGATIVE-SPACE-DIAGRAM.md @@ -0,0 +1,96 @@ +# Negative Space Diagram + +A negative space diagram is a companion diagram generated from `page-negative-space-summary` CLI output. It visualises where free routing corridors exist on the canvas — dark areas show where connectors can travel; blank (grid-visible) areas show where shapes sit. + +--- + +## Core rule + +**Black rectangles only. Shape footprints are left completely empty.** + +- Place **only solid black rectangles** (`fillColor=#1a1a1a;strokeColor=none`) where free space exists +- Where shapes are positioned, place **nothing** — leave those coordinates completely empty so the draw.io grid shows through +- Do **NOT** draw white rectangles, outlines, ghost shapes, or any visual marker at shape positions +- Do **NOT** add labels, annotations, or legend entries inside the main canvas area +- **One color only: `#1a1a1a`** — no distinctions between inter-row gap bands and within-row free corridors; all free space is the same black + +The resulting diagram is a pure "ink = free space, blank = occupied" map. + +--- + +## Construction steps + +1. Run `page-negative-space-summary` on the source diagram to get the free corridor data +2. For each free corridor `{xMin, xMax, yMin, yMax}` from `freeCorridors` or `textAwareFreeCorridors`, place one black rectangle covering that exact area +3. For inter-row gaps (y bands between shape rows), place a full-width black rectangle covering the entire gap band — same `#1a1a1a` color, no special treatment +4. For shape footprint coordinates — place **nothing**; those cells remain blank (grid visible) +5. Add a **border frame** — 4 black rectangles forming a closed frame around the entire diagram canvas (see Border frame rule below) +6. A legend may appear **outside** the diagram canvas bounds (above or below) — it must not overlap any part of the canvas + +### Border frame rule + +Every negative space diagram **must** have a 4-rectangle black border frame surrounding the canvas area. The frame consists of: + +| Side | Position | Formula | +|---|---|---| +| Top | above diagram | `x = diagramXMin - frameThickness`, `y = diagramYMin - frameThickness`, `width = diagramWidth + 2×frameThickness`, `height = frameThickness` | +| Bottom | below diagram | `x = diagramXMin - frameThickness`, `y = diagramYMax`, `width = diagramWidth + 2×frameThickness`, `height = frameThickness` | +| Left | left of diagram | `x = diagramXMin - frameThickness`, `y = diagramYMin - frameThickness`, `width = frameThickness`, `height = diagramHeight + 2×frameThickness` | +| Right | right of diagram | `x = diagramXMax`, `y = diagramYMin - frameThickness`, `width = frameThickness`, `height = diagramHeight + 2×frameThickness` | + +Where: +- `frameThickness` = 40 pt (divisible by 40 — mandatory) +- `diagramXMin`, `diagramXMax`, `diagramYMin`, `diagramYMax` — from the `page-negative-space-summary` output fields +- `diagramWidth = diagramXMax - diagramXMin` +- `diagramHeight = diagramYMax - diagramYMin` + +All four frame rectangles use `fillColor=#1a1a1a;strokeColor=none;` — same as all other free-space rectangles. + +**Example** for `diagramXMin=80, diagramXMax=1600, diagramYMin=40, diagramYMax=720, frameThickness=40`: + +``` +Top: x=40, y=0, width=1600, height=40 +Bottom: x=40, y=720, width=1600, height=40 +Left: x=40, y=0, width=40, height=760 +Right: x=1600, y=0, width=40, height=760 +``` + +Note: the left and right bars span the full height including top and bottom bars (height = diagramHeight + 2×frameThickness = 680 + 80 = 760) so corners are fully covered with no gaps. + +--- + +## XML pattern + +All rectangles use the same style — no exceptions: + +```xml + + + +``` + +Use the `textAwareFreeCorridors` field from `page-negative-space-summary` — it gives the pre-computed black rectangle list per row, already accounting for text regions inside shape bounding boxes. + +--- + +## File naming + +Place the negative space diagram alongside the source diagram with a `-negative-space` suffix: + +``` +artifacts/ +├── buyer-purchase-journey.drawio +└── buyer-purchase-journey-negative-space.drawio +``` + +--- + +## Validation + +Validate with the standard `validate` action before finishing: + +```bash +node dist/cli/commands.js --action validate --file +``` + +Expected: `valid: true`. The diagram will have 0 edges (only vertex rectangles). diff --git a/SKILL-RULES-LAYOUT.md b/SKILL-RULES-LAYOUT.md new file mode 100644 index 0000000..3ca998f --- /dev/null +++ b/SKILL-RULES-LAYOUT.md @@ -0,0 +1,492 @@ +# drawio-main — Layout Rules + +Connector placement and layout rules for draw.io diagrams generated by this skill. +These rules are **MANDATORY** — apply them when generating any diagram. + +--- + +## General layout rules — MANDATORY + +- Use a **10pt grid**; align all shapes to grid increments +- Leave ≥ 40px horizontal and ≥ 40px vertical gaps between shape columns/rows for connector corridors +- Keep connector labels short; place them in clear corridors, never overlapping shapes or other labels +- Before finalising, verify: no shape overlap, no connector-shape overlap, no label overlap, no page clipping +- **Universal 40pt spacing rule** — every shape must have at least 40pt of clear space on **all four sides**: + - **From parent header bottom**: child `y = startSize + 40` (measured from top of swimlane including header) + - **From parent sides**: child `x ≥ 40` from the left/right inner border of the swimlane + - **From parent bottom**: bottom of last child row + 40 = swimlane total height + - **Between siblings** (same nesting level): gap between any two adjacent shapes (horizontal or vertical) = 40pt exactly — all sibling gaps must be identical + + Swimlane height formulas: + - Single row: `total_height = startSize + 40 + child_height + 40` + - Two rows: `total_height = startSize + 40 + row1_h + 40 + row2_h + 40` + - Round up total to the nearest multiple of 40 if needed; absorb any remainder into the bottom gap (≥ 40) + + Examples: + - `startSize=40`, 1 row h=40: total = 40+40+40+40 = 160 (children at y=80, children w=160 with x-gap=40 between them) + - `startSize=40`, 1 row h=80: total = 40+40+80+40 = 200 (children at y=80) + - `startSize=40`, 2 rows h=40 each: total = 40+40+40+40+40+40 = 240 (row1 at y=80, row2 at y=160) + + After generating or editing a diagram, run `page-spacing-audit` and `page-swimlane-audit` to verify. + +--- + +## Diagram Layers and Connector Rules — MANDATORY + +### Layer model + +| Layer | Contents | Connector may overlap? | +|---|---|---| +| **0 — Background / containers** | Swimlanes, zone boundaries, grouping rectangles | **YES** — connectors may pass through containers | +| **1 — Primary shapes** | Core nodes: main system boxes, components, use cases | **NEVER** | +| **2 — Actor shapes** | Human actors (stick figures), external IT systems | **NEVER** | +| **3 — Connectors** | Edges, waypoints, routing corridors | **AVOID** — crossing other connectors is a last resort | +| **4 — Labels / annotations** | Edge labels, callouts, legend text, title blocks | **NEVER** | + +> **Core rule: a connector may only overlap Layer 0. It must NEVER overlap Layers 1, 2, or 4.** + +### Label-crossing prohibition — MANDATORY + +**A connector must NEVER pass through the textual label of any shape — regardless of which layer the shape belongs to.** + +- Container shapes (Layer 0 / swimlanes) have a header bar at the top that contains the label. Connectors must not pass through this header area +- For swimlanes with `startSize=40`, the header occupies `y` to `y+40`. Any connector that enters or exits vertically through the header is forbidden; route it through the side or bottom instead +- Leaf shapes (Layer 1/2) also carry labels — their entire bounding box is off-limits (existing rule), so this adds no extra constraint for those +- To check: identify every shape whose label bounding box (header bar for containers, full box for leaves) intersects a connector segment. Reroute any that does + +### Corner-cutting prohibition — MANDATORY + +**A connector must NEVER route across the corner of any shape — regardless of which layer the shape belongs to.** + +A corner cut occurs when an orthogonal connector exits from one face of a shape and its first waypoint is positioned diagonally (same quadrant as the corner) relative to that exit point, causing the L-shaped segment to visually "clip" the corner region. This is forbidden even when the corner is technically outside the shape bounding box. + +Rule: after choosing the exit face and direction of travel, the first waypoint must be placed such that the initial segment travels **parallel to or away from** the corner edge, not at an angle toward it. + +| Exit face | First segment must go | Forbidden direction | +|---|---|---| +| Bottom | Down (↓) or laterally clear | Up (↑) toward corner | +| Top | Up (↑) or laterally clear | Down (↓) toward corner | +| Left | Left (←) or clear | Right (→) toward corner | +| Right | Right (→) or clear | Left (←) toward corner | + +### Prefer-downward routing — MANDATORY + +**When a connector can reach its target by routing either upward or downward, always prefer the downward route.** + +Routing upward is only permitted when: +- The target is physically above the source (the upward direction is the natural one), OR +- Routing downward would create a connector-shape overlap that cannot otherwise be avoided + +Violation example: a connector exits the bottom of a source shape, immediately routes **up** through the source's parent container header, travels horizontally, then comes back **down** — this is forbidden. The first segment after exiting the bottom face must go **down**, not up. + +Decision rule: +1. Identify whether target `y_center` is above or below source `y_center` +2. If below (or same level): exit source bottom or side → route **downward** → enter target top or side +3. If above: exit source top or side → route **upward** → enter target bottom or side +4. If the natural downward path is blocked, use a side-exit (left or right) corridor to bypass, then continue downward — do NOT reverse direction to go upward + +### Header-edge prohibition — MANDATORY + +**A connector must NEVER run along (or within 1px of) the bottom edge of a swimlane/container header bar.** + +The header bar of a swimlane occupies the band `[shape.y .. shape.y + startSize]`. Its **bottom edge** is the line `y = shape.y + startSize`. Connectors that travel horizontally across this line are visually indistinguishable from the header border and create a confusing, cluttered diagram. + +- For a swimlane with `startSize=40`, the forbidden horizontal band is within 1px of `shape.y + 40` +- Connectors entering or exiting the swimlane via a **vertical segment that straddles** the header-bottom edge are also forbidden +- **Fix:** reroute the horizontal segment to travel either: + - **Inside** the swimlane body (below `shape.y + startSize + TOL`), or + - **Outside / above** the swimlane entirely (above `shape.y - TOL`), or + - Via a side corridor that avoids the header band altogether + +**Detection:** `page-connectors-validation` action reports `headerEdgeViolations` — always run it before finishing any diagram that contains swimlanes or container shapes. + +| Situation | Fix | +|---|---| +| Horizontal segment at `y ≈ container.y + startSize` | Move segment down into the swimlane body or up above the container | +| Vertical segment straddles header-bottom edge from inside | Re-enter the swimlane via left or right face instead | + | Multiple connectors fanning across the Data Layer header | Use a horizontal corridor at `y = container.y + startSize + 40` (center of the 40pt gap) or route via side bypass | + +### Connector-crossing avoidance — MANDATORY + +**Connectors must not cross each other.** Crossing another connector is a last resort — acceptable only when no feasible reroute exists. When a crossing is unavoidable, it must be a clean 90° crossing. Connectors crossing other connectors is always preferable to a connector crossing a shape label or shape corner. + +| Cause | Fix | +|---|---| +| Multiple left-side sources fan into one target | Route through a shared left-side vertical corridor; stagger y-entry points | +| Sources on both sides of a central shape | Use separate left-corridor and right-corridor | +| Sources in different rows heading to the same column | Give each source its own horizontal lane in the corridor | +| Long connector crosses shorter ones | Route long connectors through Y_TOP (above all shapes) | + +**Crossing-free strategy:** +1. Identify source groups and target groups +2. Assign each source group its own corridor lane +3. Route connectors in each group in parallel through their lane +4. Trace every connector pair — if any two intersect at a non-edge point, redesign + +If a crossing truly cannot be avoided, prefer a right-angle (90°) crossing over an oblique one. + +### Minimal waypoints rule — MANDATORY + +**Add waypoints only when the direct path would clip a Layer 1/2/4 shape.** + +| Situation | Waypoints? | +|---|---| +| Source and target at same horizontal level, path is clear | **NO** | +| Adjacent columns with clear gap between them | **NO** | +| Source mid_y falls within target's y-range, path is clear | **NO** | +| Path would clip an intermediate shape | **YES** — minimum waypoints to route around it | +| Fan-out from a central shape to a stacked column | **YES** — use Pattern E | + +**Before adding a waypoint, ask:** *"Would the direct auto-routed path clip any Layer 1/2/4 shape?"* If no — no waypoints. If yes — first try repositioning shapes (layout-first principle). + +**Keep all connectors the same edge style** (`edgeStyle=orthogonalEdgeStyle`). Never change edge style to work around a layout problem — fix the layout instead. + +### Layout-first principle + +**Redesign the layout to eliminate waypoints before adding them.** + +| Problem | Layout fix | +|---|---| +| Actor above target's y-range | Move target up, or tighten actor spacing so target mid_y aligns | +| Actor below target's y-range | Move target down, or tighten actor spacing | +| Fan-out column spans much more than source | Centre source vertically on the column | + +**Shape size is driven by content, not routing** — typically 80–160px tall. If only 1–2 actors fall outside the target's y-range, accept 2 minimal waypoints per connector rather than inflating the shape. Space actors 80–120px apart (centre-to-centre) to keep groups compact. + +### When auto-routing is forbidden + +Never rely on `edgeStyle=orthogonalEdgeStyle` without explicit waypoints when the path passes through intermediate shapes. Use waypoints only where needed — the goal is the minimum number that achieves zero overlaps and zero crossings. + +--- + +## Connector Routing Patterns + +### Corridors + +Before placing connectors, identify clear **corridors** — bands free of all shapes. + +**Vertical corridor** between two columns: +``` +GAP_x = (right_edge_of_left_column + left_edge_of_right_column) / 2 +``` + +**Horizontal corridor** between two rows: +``` +GAP_y = (bottom_edge_of_upper_row + top_edge_of_lower_row) / 2 +``` + +Reserve a **top corridor** (`Y_TOP`) above all shapes and a **bottom corridor** (`Y_BELOW`) below all shapes for cross-diagram connectors. + +### Absolute coordinates + +Connector waypoints are canvas-absolute. For nested containers, sum all ancestor offsets: +``` +abs_x = shape.x + parent.x + grandparent.x + ... +abs_y = shape.y + parent.y + grandparent.y + ... +``` + +### Overlap verification (Python) + +```python +TOL = 2 + +def segment_overlaps_shape(seg, shape): + sx1, sy1, sx2, sy2 = shape + if seg['type'] == 'H': + Y, xA, xB = seg['y'], min(seg['x1'],seg['x2']), max(seg['x1'],seg['x2']) + if sy1+TOL < Y < sy2-TOL and max(xA,sx1+TOL) < min(xB,sx2-TOL): + return True + elif seg['type'] == 'V': + X, yA, yB = seg['x'], min(seg['y1'],seg['y2']), max(seg['y1'],seg['y2']) + if sx1+TOL < X < sx2-TOL and max(yA,sy1+TOL) < min(yB,sy2-TOL): + return True + return False +``` + +Exclude the terminal shape (source/target) when checking first/last segments. + +### Pattern A — Top highway + +Long connectors that span the full diagram width: +``` +source → V up to Y_TOP → H across → V down to target +``` + +### Pattern B — Stacked column approach + +Multiple targets stacked vertically in a column — never route through the column. Approach each from a corridor to the side: +``` +source → V to Y_TOP → H to GAP_x (beside column) → V to target_mid_y → H into target +``` + +### Pattern C — Narrow gap corridor + +When two adjacent shapes leave a small gap (≥ 10px), that gap is a usable vertical corridor: +``` +corridor_x = (shape_A_right + shape_B_left) / 2 +``` + +### Pattern D — Bypass around a dense row + +When a row of shapes blocks a path to shapes below, go around the right edge: +``` +source → H right to RIGHT_BYPASS → V to target_y → H left to target +RIGHT_BYPASS = (rightmost_shape_right + next_column_left) / 2 +``` + +### Pattern E — Fan-out from a central shape to a stacked column + +The most common overlap scenario: one central shape connects to N stacked targets on one side. + +**Solution — vertical fan-out corridor:** +1. `GAP = (central_right + column_left) / 2` +2. Exit central shape → H to GAP → V to target_mid_y → H into target + +```xml + + + + +``` + +**Example** — source mid_y=200, GAP=820, 5 stacked targets: + +| Target | mid_y | Waypoints | +|---|---|---| +| Item A | 80 | (820, 200) → (820, 80) | +| Item B | 200 | direct (no waypoints needed) | +| Item C | 320 | (820, 200) → (820, 320) | +| Item D | 440 | (820, 200) → (820, 440) | +| Item E | 560 | (820, 200) → (820, 560) | + +Constraint: `central_right < GAP < column_left`. If gap < 40px, move the column right. + +### Waypoint XML format + +```xml + + + + + + + + + +``` + +Rules: +- Waypoints are canvas-absolute +- Edges are children of root layer (`parent="1"`), never of a container +- Last waypoint stops at the corridor boundary — draw.io completes the final stub + +### Layout checklist + +- [ ] ≥ 40px gaps between columns and rows (universal 40pt spacing rule) +- [ ] Y_TOP corridor reserved above all shapes +- [ ] Y_BELOW corridor reserved below all shapes +- [ ] Corridor bands contain no shapes +- [ ] Python overlap verification run; all overlaps fixed +- [ ] Endpoint-touch exclusion applied for first/last segments +- [ ] Every connector pair traced — no path intersections at non-edge points +- [ ] All connector labels in clear corridors, not overlapping shapes or other labels +- [ ] No connector passes through any shape's text label (including swimlane header bars) +- [ ] No connector cuts across a shape corner (first segment after exit travels away from corners) +- [ ] No connector runs along (or within 1px of) the bottom edge of any swimlane/container header bar (`page-connectors-validation` reports `headerEdgeViolations`) +- [ ] All connectors route downward when target is below source; upward only when target is above source + +--- + +## Swimlane / Multi-Zone Diagram Routing + +When a diagram uses **swimlane zones** (horizontal bands, each a `swimlane` container), apply the following rules in addition to all rules above. + +### Zone layout reference + +For a typical layered architecture with `startSize=40` swimlanes and 40pt spacing: + +``` +zone.y ← swimlane top edge +zone.y + 40 ← header bottom (label occupies y..y+40) +zone.y + 40 + 40 = zone.y+80 ← first child row top (rel y=80 inside container) +zone.y + 80 + child_h ← first child row bottom +... +zone.y + height ← swimlane bottom edge +``` + +Inter-zone gaps (between adjacent swimlanes) are clean horizontal corridors — use their midpoint as the H-travel y-coordinate for connectors crossing zone boundaries. + +### Bypass corridors + +Always reserve **two vertical bypass corridors** outside all zones: + +| Corridor | x value | Rule | +|---|---|---| +| LEFT bypass | `zone.x - 20` (e.g. x=60 when zones start at x=80) | All leftward cross-zone connectors | +| RIGHT bypass | `zone.x + zone.width + 20` (e.g. x=1260 when zones end at x=1240) | All rightward cross-zone connectors | + +These bypass corridors run the full canvas height and are free of all shapes. **Every connector that must travel between zones should route through one of these corridors.** + +### Multi-row swimlane: horizontal segment placement + +When a swimlane has **two rows of shapes** (row1 and row2), each row has a y-range. Never place a connector H segment at a y-value that falls inside a row's y-range — that will overlap sibling shapes. + +Use only these safe H corridors inside a multi-row services swimlane: + +| Corridor | y value | When to use | +|---|---|---| +| Inter-row gap | `row1_bottom + (row2_top - row1_bottom)/2` | H travel between row1 and row2 shapes | +| Services-bottom gap | `row2_bottom + (zone_bottom - row2_bottom)/2` | H travel below row2, still inside zone | +| Inter-zone gaps | midpoint of gap between adjacent zones | H travel outside zones | + +**Example** — services-zone: startSize=40, zone.y=500, row1 abs y=580..620, row2 abs y=660..700, zone bottom=740: +- Inter-row gap corridor: **y=640** (midpoint of y=620..660) +- Services-bottom corridor: **y=720** (midpoint of y=700..740) + +### Connector routing rules for multi-row swimlanes + +**Rule 1 — Never route H segments through a row's y-range.** +If a connector must travel horizontally through the services zone, use y=640 (inter-row) or y=720 (services-bottom), never y=580..620 or y=660..700. + +**Rule 2 — When exiting a shape in row2 that has sibling shapes to its right in the same row:** +Do NOT exit from the bottom of the shape and then travel H at y=720 (services-bottom) to the right — this H will cross the V stubs of right-side siblings that exit to the same corridor. + +**Fix options:** +- a) Exit from the **right side** of the shape → immediately go to RIGHT bypass x=1260 → V down/up to target (no H inside zone) +- b) If right-side exit H would cross a sibling shape body: exit right → first waypoint in the **column gap** to the right (e.g. x=1060 for col5/col6 gap) → V down to services-bottom corridor y=720 → H right to RIGHT bypass → V to target + +**Rule 3 — Left bypass for connectors going downward to lower zones.** +When a row1 or row2 shape must connect to a shape in a lower zone (messaging, data), route: +``` +shape_bottom → inter-row corridor y=640 → H LEFT to x=LEFT_BYPASS → V down to target zone corridor → H right to target +``` +This ensures the H segment travels in the clear inter-row gap corridor and never crosses sibling shapes. + +**Rule 4 — connectorShapeOverlaps with zone containers are structural and unavoidable.** +Any connector crossing a swimlane zone boundary will be flagged as `connector_shape_overlap` with the zone container. This is expected and acceptable — focus on eliminating `connectorCrossings` (two connectors intersecting each other) and overlaps with **non-container leaf shapes**. + +### Pattern F — Multi-row swimlane fan-out (api-gateway → many services) + +When a shape in an upper zone connects to N shapes spread across multiple columns of a multi-row services swimlane: + +``` +api-gateway_bottom → H to LEFT_BYPASS at inter-zone gap y → V down LEFT_BYPASS → + branch per target: + - col1 (leftmost): H right from LEFT_BYPASS to target_center_x at inter-row gap y + - col2..colN: same pattern, extending H further right + - for row2 targets: V from inter-row corridor down to row2 top, enter from top +``` + +All H branches travel at the same y (inter-row gap) — they are **parallel**, not crossing. + +### Pattern G — Event bus → services (right bypass fan-in) + +When an event bus (bottom of diagram) connects back up to multiple services: + +``` +event-bus_right → H right to RIGHT_BYPASS → V up → + branch per target: + - service in row1: H left from RIGHT_BYPASS at row1_center_y to target + - service in row2: stop at services-bottom corridor y=720 → H left → V up 20pt to target bottom + - stagger y values slightly (e.g. y=720 for one, y=730 for another) to avoid parallel confusion +``` + +**Critical:** The H segments going LEFT from RIGHT_BYPASS at y=720/730 will cross V stubs of row2 services that exit their bottoms and route DOWN and RIGHT to the right bypass. To avoid these crossings: +- Route those downward connectors via the **LEFT bypass** instead (Pattern F inverse) +- OR ensure the rightward services exit via their **right side** (not bottom), so no V stub exists at y=720..730 + +### Swimlane validation workflow + +1. Run `page-connectors-validation` after every edit +2. Fix `connectorCrossings` first — these are always avoidable +3. Fix `connectorShapeOverlaps` with **leaf shapes** (non-containers) — these are overlaps with actual service boxes +4. Accept `connectorShapeOverlaps` with zone containers as structural (unavoidable) +5. Fix `cornerPortViolations`, `headerEdgeViolations`, `singlePortViolations` — all should reach 0 +6. Target: `connectorCrossings=0`, `cornerPortViolations=0`, `headerEdgeViolations=0`, `singlePortViolations=0` + +--- + +## Sequence Diagram Layout Rules — MANDATORY + +Sequence diagrams have a fundamentally different structure to architecture diagrams. The rules below override or supplement the general connector rules **for sequence diagrams only**. + +### Participants (lifeline headers) + +- **Participant boxes** are rectangular shapes (`rounded=0`) placed in a single horizontal row at the top of the diagram +- **HUMAN_ACTOR participants** use `shape=actor`, `width=40`, `height=40` — same as all actor shapes +- **Service/system participants** use `rounded=0`, `width=120`, `height=80` (both divisible by 40) +- **Participant spacing**: gap between adjacent participant boxes must be ≥ 40pt and divisible by 40. Preferred gap = 80pt, giving a step of `width + 80` between participant x-origins +- **Participant x-origin**: must be on the 10pt grid; preferred to be on the 40pt grid +- **All participant boxes must have the same height** (uniform row) + +### Lifelines + +- Each participant has exactly one **lifeline** — a vertical dashed edge (`dashed=1; endArrow=none`) centered on the participant +- Lifeline x-coordinate = `participant.x + participant.width / 2` (center of participant) +- Lifeline starts at `y = participant.y + participant.height` (bottom edge of participant box) and ends above the legend/footer area. Stop lifelines at least 40pt above the legend box top edge +- Lifeline color matches the participant stroke color +- **Lifelines are Layer-0 elements** — they are background structural elements, equivalent to swimlane containers. Connectors (message arrows) may cross lifelines freely — this is the defining visual characteristic of a sequence diagram + +### Message arrows + +- Message arrows are horizontal edges (`endArrow=block; endFill=1`) drawn at exact y-coordinates, traveling between lifeline x-positions +- **Each message occupies its own y-row** — messages must never share the same y-coordinate +- **Vertical step between messages**: minimum 20pt, preferred 20–40pt to ensure labels don't overlap +- **Message arrow y-coordinates** must be on the 10pt grid +- **Label placement**: edge label is above the arrow line; keep it short (≤ 30 chars) to avoid overlap with lifelines it crosses +- **Return messages** (response arrows) travel in the opposite horizontal direction; they are drawn 20pt below the corresponding request arrow + +### Step / phase labels + +- Phase separator labels ("1. Search & Browse", etc.) are text shapes (`style=text`) positioned in a **left margin column** that must not overlap any lifeline +- Left margin column: `x = 20`, `width ≤ participant_leftmost_x - 20 - 4` (at least 4pt clear gap from the leftmost lifeline) +- Phase labels are placed at the y-coordinate of the first message in that phase, minus 20pt + +### Lifeline × message crossing — STRUCTURAL EXEMPTION + +**Lifeline × message arrow crossings are structural and expected in sequence diagrams.** They must NOT be treated as violations. The `page-connectors-validation` tool will report these as `connectorCrossings` — accept them without fix. + +Specifically: +- A vertical lifeline edge crossing a horizontal message arrow edge = **structural, expected, acceptable** +- Only report as a true crossing violation: two message arrows crossing each other (both horizontal) or two lifelines crossing each other (impossible by construction) + +This exemption applies only to lifeline edges (identified by: `dashed=1; endArrow=none; vertical segment spanning many messages`). All other crossing rules remain fully in force. + +### Legend container — STRUCTURAL EXEMPTION + +Legend sample arrows (short colored stubs showing connector styles) placed inside a plain container shape will be reported as `connectorShapeOverlaps` with the container. This is structural and expected — the legend connectors are intentionally drawn inside the container boundary. + +Accept `connectorShapeOverlaps` where: +- The overlapping edge is a legend sample stub (short, non-connected edge with `parent=legend-box`) +- The overlapping shape is the legend container itself (`id=legend-box`) + +All other `connectorShapeOverlaps` with non-container leaf shapes must still be fixed. + +### Sequence diagram validation targets + +| Metric | Target | Notes | +|---|---|---| +| `connectorCrossings` (lifeline × message) | **Accepted as structural** | Expected behavior of sequence diagrams | +| `connectorCrossings` (message × message) | **0** | Two horizontal messages must never cross | +| `connectorShapeOverlaps` (legend stubs × legend box) | **Accepted as structural** | Legend connectors inside their container | +| `connectorShapeOverlaps` (message × participant box) | **0** | Message arrows must not pass through participant boxes | +| `connectorShapeOverlaps` (lifeline × step-label shape) | **0** | Step labels must be in a clear left margin column | +| `cornerPortViolations` | **0** | | +| `headerEdgeViolations` | **0** | | +| `singlePortViolations` | **0** | | +| `overlappingPairs` (shape bbox) | **0** | No shape bounding boxes overlap | + +### Sequence diagram layout checklist + +- [ ] All participants in one horizontal row, uniform height, gaps divisible by 40 +- [ ] HUMAN_ACTOR participants: `shape=actor`, width=40, height=40 +- [ ] Service participants: `rounded=0`, width=120, height=80 +- [ ] Lifelines centered on participants, stop 40pt above legend +- [ ] Lifelines use `dashed=1; endArrow=none`; color matches participant stroke +- [ ] Each message at a unique y-coordinate, on 10pt grid, min 20pt apart +- [ ] Step/phase labels in left margin column (x=20, clear of all lifelines) +- [ ] No message × message crossings (horizontal × horizontal) +- [ ] No message overlapping a participant box +- [ ] Legend items are children of a plain container (not swimlane) +- [ ] `page-shape-bbox-validation` reports `overlappingPairs=0` +- [ ] `page-connectors-validation` reports `cornerPortViolations=0`, `headerEdgeViolations=0`, `singlePortViolations=0` diff --git a/SKILL-RULES-STYLE.md b/SKILL-RULES-STYLE.md new file mode 100644 index 0000000..d63b87c --- /dev/null +++ b/SKILL-RULES-STYLE.md @@ -0,0 +1,71 @@ +# drawio-main — Style Rules + +Visual appearance and sizing rules for draw.io diagrams generated by this skill. +These rules are **MANDATORY** — apply them when generating any diagram. + +--- + +## General style rules — MANDATORY + +- **All child shapes within the same swimlane must have the same height** — never mix different heights within one swimlane layer/row +- **Rectangles must never use rounding** — always set `rounded=0` on rectangle shapes +- Prefer dimensions divisible by 40; wrap non-divisible vendor icons in a grid-aligned container +- **The space between sibling shapes of the same level (same swimlane, same row) must be divisible by 40px** — use 40px as the minimum gap. If a swimlane row has shapes of width W and gap G, the step between shape origins = W + G where G ≥ 40 and G mod 40 = 0. +- **Swimlane `startSize` (header height) must be divisible by 40** — use `startSize=40` +- **Swimlane body height (total − startSize) must be divisible by 40** — so total height = 40 + N×40 = a multiple of 40 +- Example: `startSize=40`, body=80 → total=120 ✓; body=160 → total=200 ✓ +- Use ≤ 3 primary color families; create hierarchy with shades +- Use dashed connectors only for semantically distinct flows (async, backup, admin) +- Shape size is driven by **content**, not routing — typically 80–160px tall per label line + +--- + +## Connector routing rules — MANDATORY + +- **All outgoing connectors from the same shape must share a single exit point** — never let multiple connectors leave a shape at different X/Y coordinates. Pick one exit side (top/bottom/left/right) and one coordinate on that side; all outgoing connectors use it as their first waypoint. +- **All incoming connectors to the same shape must share a single entry point** — never let multiple connectors enter a shape at different X/Y coordinates. Pick one entry side and one coordinate; all incoming connectors use it as their last waypoint before the shape. +- **Connectors must never attach at a shape corner** — the exit/entry point must lie on the middle of a side: top-center, bottom-center, left-center, or right-center. A point that is simultaneously on both an X-edge (left or right) AND a Y-edge (top or bottom) of the shape bounding box is a corner and is forbidden. Use the midpoint of the chosen side: `x_mid = (x1+x2)/2` for top/bottom sides, `y_mid = (y1+y2)/2` for left/right sides. +- These rules ensure a clean "bus-bar" fan-out/fan-in pattern and prevent connectors from diverging at their source or converging at their target at different positions, which creates visual clutter and increases crossing risk. +- Exception: shapes with only a single outgoing or single incoming connector — the single-port rule trivially holds. The corner rule still applies. +- When computing waypoints: use `page-negative-space-summary` to find free X corridors per row, then pick an exit/entry coordinate that lies within a free corridor at the next traversed level. This minimises connector-shape overlaps and connector crossings. +- **Waypoints must never be closer than 20px to any shape** — the first waypoint after a shape exit must be at least 20px away from the shape's edge in the direction of travel (e.g., if exiting bottom at y=520, first waypoint y ≥ 540; if exiting right at x=1040, first waypoint x ≥ 1060). The last waypoint before a shape entry must likewise be at least 20px away from the shape's edge. This 20px clearance also applies to any waypoint relative to same-level sibling shapes the connector passes by — the waypoint must not come within 20px of any sibling shape's bounding box side it is adjacent to. + +--- + +## Text-width estimation — negative space calculation + +When computing negative space manually (e.g. for a negative-space diagram), **shape bounding boxes alone overestimate occupied space**. Text labels only occupy a sub-region within the shape bbox. Use the following formula to estimate the text rendering width: + +``` +charWidth = fontSize × 0.6 // avg glyph width for proportional fonts +rawWidth = longestLineChrCount × charWidth +padding = fontSize × 1.0 // horizontal padding (~0.5em each side) +textWidth = rawWidth + padding + +fontStyle modifiers (draw.io flags): + bit 0 (value 1) = bold → charWidth × 1.10 + bit 1 (value 2) = italic → charWidth × 1.05 + +textXMin = clamp(shapeCenterX − textWidth/2, shape.xMin, shape.xMax) +textXMax = clamp(shapeCenterX + textWidth/2, shape.xMin, shape.xMax) +``` + +**When to apply:** +- Swimlane headers (`startSize=40` band): the header text is centered in the full zone width. Use text region for occupied X, flanks are free negative space. +- Large container shapes whose children don't fill the full width. +- Any shape where `textWidth < shapeWidth` — the flanks inside the bbox are free. + +**Example — zone headers at fontSize=12 bold (fontStyle=1), center x=660, zone x=80..1240:** + +| Label | chars | charWidth | rawWidth | padding | textWidth | textXMin | textXMax | flank each side | +|---|---|---|---|---|---|---|---|---| +| "Client Layer" | 12 | 7.9 | 95 | 12 | 107 | 607 | 713 | ~527pt | +| "API Gateway / Edge Layer" | 24 | 7.9 | 190 | 12 | 202 | 559 | 761 | ~479pt | +| "Core Microservices" | 18 | 7.9 | 143 | 12 | 155 | 583 | 737 | ~503pt | +| "Messaging / Event Bus" | 21 | 7.9 | 166 | 12 | 178 | 571 | 749 | ~491pt | +| "Data Layer" | 10 | 7.9 | 79 | 12 | 91 | 615 | 705 | ~535pt | + +**The `page-negative-space-summary` action now outputs these fields per shape:** +- `textXMin`, `textXMax`, `textWidth` — estimated text rendering region +- `textFlankLeft`, `textFlankRight` — free space flanking text inside bbox +- `rows[].textAwareFreeCorridors` — free corridors computed using text regions (wider than bbox-based corridors) diff --git a/SKILL-XML-REFERENCES.md b/SKILL-XML-REFERENCES.md new file mode 100644 index 0000000..281745a --- /dev/null +++ b/SKILL-XML-REFERENCES.md @@ -0,0 +1,485 @@ +# draw.io XML Reference + +Detailed reference for styles, edge routing, containers, layers, tags, metadata, and dark mode. Consult this when generating draw.io XML diagrams. + +## Reasoning budget (read this first) + +Your job is to declare the **logical structure** of the diagram — what nodes exist, what edges connect them, what labels they carry, what lane/container groups them. draw.io's edge router and (when available) a post-layout pass handle routing and placement; you do **not** need to do layout math. + +**Do NOT** in your reasoning: + +- Do NOT debate the topic. The user asked for a flowchart / architecture / sequence / etc. — pick one concrete scenario on your first impulse and commit. Never write "Actually, let me think of something else…" or pitch alternatives. +- Do NOT debate flat-lanes vs nested-pools, horizontal vs vertical orientation, one vs multiple variations. Pick the first reasonable option (almost always: flat swimlanes, top-down or left-right based on what fits the content). Do not flip-flop. +- Do NOT compute x/y coordinates in prose. No "column spacings of 160px totaling 1840px width — that's too wide, let me tighten to 1700…" loops. Use the rigid grid below; do the arithmetic in your head and write the XML. +- Do NOT re-derive drawio mechanics (`horizontal=0`, `startSize=110`, nested-lane coordinates). Use the templates below as-is. +- Do NOT enumerate columns ("customer lane columns 0-10, web app 1-7"). Place a node, move on. +- Do NOT add `` waypoints. Edges are routed automatically. +- Do NOT set `exitX` / `exitY` / `entryX` / `entryY` connection-point overrides unless you have specific geometric intent. +- Do NOT verify, re-check, or adjust coordinates after placing a node. +- Do NOT narrate "building the diagram / finalizing the XML / now let me…". Just emit XML. +- Do NOT write out lists of node positions as planning text. Emit them as `` elements directly. + +**Do** in your reasoning: + +- Identify the diagram type + actors/stages (1-2 short sentences). +- Identify any grouping (swimlanes? containers? none?). +- Go straight to XML. + +**Rigid grid — use for every XML diagram:** + +- Column x = `col_index * 180 + 40` (col 0 = 40, col 1 = 220, col 2 = 400, …) +- Row y = `row_index * 120 + 40` (row 0 = 40, row 1 = 160, row 2 = 280, …) +- Node size: rectangles `140×60`, diamonds `140×80`, circles `60×60`, documents `120×80`, cylinders `100×70` + +Pick a `(col, row)` for each node. Don't think about centers, gaps, or overlap — ELK handles routing between rough positions. Slight misalignment is invisible in the result. + +## General principles + +- **Use proper draw.io shapes and connectors** — choose the semantically correct shape for each element (e.g., `shape=cylinder3` for databases and tanks, `rhombus` for decisions, `shape=mxgraph.pid2valves.*` for valves in P&IDs). draw.io has extensive shape libraries; prefer domain-appropriate shapes over generic rectangles. +- **Decide whether to search for shapes** — before generating a diagram, decide if it needs domain-specific shapes from draw.io's extended libraries. **Skip `search_shapes`** for standard diagram types that use basic geometric shapes: flowcharts, UML (class, sequence, state, activity), ERD, org charts, mind maps, Venn diagrams, timelines, wireframes, and any diagram using only rectangles, diamonds, circles, cylinders, and arrows. Also skip if the user explicitly asks to use basic/simple shapes or says not to search. **Use `search_shapes`** when the diagram requires industry-specific or branded icons: cloud architecture (AWS, Azure, GCP), network topology (Cisco, rack equipment), P&ID (valves, instruments, vessels), electrical/circuit diagrams, Kubernetes, BPMN with specific task types, or any domain where the user expects realistic/standardized symbols rather than labeled boxes. +- **Match the language of labels to the user's language** — if the user writes in German, French, Japanese, etc., all diagram labels, titles, and annotations should be in that same language. +- **Group related nodes, and surface a hub when edges converge** — put nodes that belong together inside a container or swimlane, and keep external actors (users, files, third-party systems) outside implementation containers. When many edges converge on one area or cross several groups, route them through a single hub/gateway node (a registry, broker, event log, …) instead of drawing every low-level dependency across the canvas — fewer crossings, clearer contract. +- **Encode secondary detail in node text, not edges** — draw an edge only when the relationship itself carries meaning; push incidental detail into the node label so the connector layer stays readable. + +## Common styles + +**Rounded rectangle:** +```xml + + + +``` + +**Diamond (decision):** +```xml + + + +``` + +**Arrow (edge):** +```xml + + + +``` + +**Labeled arrow:** +```xml + + + +``` + +## Style properties + +| Property | Values | Use for | +|----------|--------|---------| +| `rounded=1` | 0 or 1 | Rounded corners | +| `whiteSpace=wrap` | wrap | Text wrapping | +| `fillColor=#dae8fc` | Hex color | Background color | +| `strokeColor=#6c8ebf` | Hex color | Border color | +| `fontColor=#333333` | Hex color | Text color | +| `shape=cylinder3` | shape name | Database cylinders | +| `shape=mxgraph.flowchart.document` | shape name | Document shapes | +| `ellipse` | style keyword | Circles/ovals | +| `rhombus` | style keyword | Diamonds | +| `edgeStyle=orthogonalEdgeStyle` | style keyword | Right-angle connectors | +| `edgeStyle=elbowEdgeStyle` | style keyword | Elbow connectors | +| `dashed=1` | 0 or 1 | Dashed lines | +| `swimlane` | style keyword | Swimlane containers | +| `group` | style keyword | Invisible container (pointerEvents=0) | +| `container=1` | 0 or 1 | Enable container behavior on any shape | +| `pointerEvents=0` | 0 or 1 | Prevent container from capturing child connections | +| `html=1` | 0 or 1 | Enable HTML rendering in labels (required for ``, `
`, ``, etc.) | +| `shape=umlLifeline;perimeter=lifelinePerimeter;size=16` | shape | UML sequence diagram lifeline (size = header height) | + +## HTML labels + +**Always include `html=1` in the style** when the `value` attribute contains any HTML tags (``, `
`, ``, ``, ``, `
`, `

`, ``, etc.). Without `html=1`, HTML tags are displayed as literal text instead of being rendered. + +HTML in attribute values must be **XML-escaped**: `<` → `<`, `>` → `>`, `&` → `&`, `"` → `"` + +```xml + + + +``` + +**Line breaks:** Use ` ` (works with both `html=1` and `html=0`) or `<br>` (requires `html=1`) for line breaks — never use `\n`, which renders as literal backslash-n text instead of a newline. + +**Best practice:** Always include `html=1` in every cell style. This ensures labels render correctly whether they contain HTML or plain text — plain text is unaffected by the flag. + +**Bold/italic/underline:** Use `fontStyle` in the style string when the entire label should be bold (`fontStyle=1`), italic (`fontStyle=2`), or underline (`fontStyle=4`). Values can be combined via bitwise OR (e.g., `fontStyle=3` = bold+italic). Use HTML tags (``, ``, ``) only when formatting part of the label (e.g., bold title with normal description). Never combine `fontStyle` with HTML tags for the same effect — this is redundant and causes visible raw tags if `html=1` is missing. + +## Edges + +**CRITICAL: Every edge `mxCell` must contain a `` child element.** Self-closing edge cells (e.g. ``) are invalid and will not render correctly. Always use the expanded form: +```xml + + + +``` + +**Don't hand-route edges.** Just declare `source` and `target`. You do **not** need to: +- Add `` waypoints +- Set `exitX` / `exitY` / `entryX` / `entryY` +- Route around obstacles +- Worry about edge-vertex collisions or parallel edge spacing + +draw.io's built-in router is **basic**: it draws each edge as a straight line or a simple right-angle path between `source` and `target`, with **no awareness of other shapes** — a wire will run straight across any box that sits between its endpoints. That's fine when connected nodes have open space between them. When edges would otherwise cross over shapes, or you want consistently clean orthogonal wires that route *around* the boxes, set **`routing: "libavoid"`** on `create_diagram`; for a full re-layout use **`postLayout: "elk"`** (see **Edge routing & layout passes** below). Both compute the waypoints for you — you never add them by hand either way. + +**What you still choose: the edge style.** The style determines the overall look (orthogonal angles, curves, straight lines) — the router honors the style family. + +| Style | Syntax | Best for | +|-------|--------|---------| +| **Orthogonal** | `edgeStyle=orthogonalEdgeStyle` | Flowcharts, architecture, network diagrams, BPMN — any diagram with right-angle connectors | +| **Straight** | no `edgeStyle` | UML class/sequence diagrams, direct point-to-point connections. For sequence diagram messages use `endSize=6;startSize=6;` to keep arrowheads small | +| **Entity Relation** | `edgeStyle=entityRelationEdgeStyle` | ER diagrams — creates perpendicular stubs at both ends | +| **Curved** | `curved=1` | Mind maps, informal diagrams | +| **Elbow** | `edgeStyle=elbowEdgeStyle;elbow=vertical;` | Rarely needed — `orthogonalEdgeStyle` handles almost all cases; use this only for simple 1-bend linear flows | + +**Use a consistent edge style within each diagram.** Pick one based on diagram type and apply it to all edges: ER → `entityRelationEdgeStyle`; UML class → straight; mind maps → curved; flowcharts/architecture/network → `orthogonalEdgeStyle`. + +**Useful edge style attributes** that apply regardless of routing: +- `rounded=1` — rounded corners at bend points (recommended for orthogonal) +- `endArrow=classic` / `endArrow=none` — arrow heads +- `dashed=1` — dashed line +- `strokeColor=#...`, `strokeWidth=2` — color/width +- Edge labels: set `value` directly on the edge cell + +**Keep edge labels short and meaningful** — one to three words (`Yes`, `async`, `reads`). Drop labels that merely restate an obvious action (`call`, `register`); move longer explanations into node text or a small legend node. + +**Visual semantics — stay consistent, add a legend when mixing styles.** Within one diagram apply `dashed=1`, `strokeColor`, and `strokeWidth` consistently for one chosen meaning (e.g. dashed = optional / async / inferred relationship). Don't mix several dashed meanings without a small legend explaining them. + +## Containers and groups + +For architecture diagrams or any diagram with nested elements, use draw.io's proper parent-child containment — do **not** just place shapes on top of larger shapes. + +### How containment works + +Set `parent="containerId"` on child cells. Children use **relative coordinates** within the container. + +### Container types + +| Type | Style | When to use | +|------|-------|-------------| +| **Group** (invisible) | `group;` | No visual border needed, container has no connections. Includes `pointerEvents=0` so child connections are not captured | +| **Swimlane** (titled) | `swimlane;startSize=30;` | Container needs a visible title bar/header, or the container itself has connections | +| **Custom container** | Add `container=1;pointerEvents=0;` to any shape style | Any shape acting as a container without its own connections | + +### Key rules + +- **Edges to children inside containers naturally cross the container boundary** — this is correct and expected. Do not add extra waypoints or complex routing to avoid a parent container when connecting to shapes inside it. +- **Always add `pointerEvents=0;`** to container styles that should not capture connections being rewired between children +- Only omit `pointerEvents=0` when the container itself needs to be connectable — in that case, use `swimlane` style which handles this correctly (the client area is transparent for mouse events while the header remains connectable) +- Children must set `parent="containerId"` and use coordinates **relative to the container** + +### Example: Architecture container with swimlane + +```xml + + + + + + + + + +``` + +### Example: Invisible group container + +```xml + + + + + + +``` + +### Swimlanes for grouped actors (BPMN-style flowcharts) + +Use **flat swimlanes** at `parent="1"`, stacked vertically. One row of nodes per lane. + +**Fixed values — do not compute or debate:** +- Lane size: `x=0, y=lane_index*150, width=CANVAS_W, height=150` +- Lane style: `swimlane;horizontal=0;startSize=110;fillColor=;html=1;` +- Child nodes inside a lane: `parent=""`, `x = 120 + col*180`, `y = 45` (always 45), size 140×60 (or 140×80 for diamonds) +- Cross-lane edges: `parent="1"` (not inside a lane) + +Pick `CANVAS_W = max_col * 180 + 300`. Choose lane colors from `#f5f5f5, #e8f4f8, #fff0e6, #e8f5e9, #fff9e6, #fce4ec` in that order. + +```xml + + + + + + + + + + + + + + + +``` + +Do NOT nest lanes inside a pool. Do NOT vary lane heights. Do NOT compute title-area offset — it is always 110, children start at x=120 to clear it. + +### Nested architecture containers (cloud, infra, network topologies) + +For diagrams with **nested groupings** — VPC → Availability Zone → EC2 instance, Datacenter → Rack → Server, Region → Environment → Service — use nested swimlanes. This is where the AI most often flattens hierarchy that should be nested. Treat each level as a swimlane container. + +**Rules:** +- Every container is a `swimlane` with `startSize=24` (title area at the top). +- Child cells set `parent=""` and use coordinates **relative to their parent** (origin 0,0 is the parent's top-left, below the title). +- Edges between cells in **different** containers must have `parent="1"` (not a container) — otherwise they render inside the container and get clipped. +- For industry-specific icons (AWS/Azure/GCP logos, Cisco equipment, etc.), call `search_shapes` to get the exact `style` string and substitute it into a regular vertex — the container structure stays the same. + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +### Cross-functional flowcharts (actor × phase grid, as a table) + +Cross-functional flowcharts show a process across **two axes at once** — actors (rows) and phases (columns). Use drawio's `table` shape, which auto-arranges cells into a grid via `childLayout=tableLayout`. This is the canonical draw.io pattern and is distinct from plain swimlanes (which only group on one axis). + +**Structure:** +- Outer container: `shape=table;childLayout=tableLayout;startSize=0;collapsible=0;fillColor=none;` +- Rows are children of the table: `shape=tableRow;horizontal=0;startSize=0;collapsible=0;` +- Cells are children of rows — regular vertices, one per (actor, phase) intersection +- Row heights and cell widths are set via `mxGeometry`; they tile automatically +- First row = phase headers; first cell of every other row = actor label +- Process nodes go INSIDE the appropriate cell (parent = cell id) at coordinates relative to the cell +- Cross-cell edges must use `parent="1"` (same rule as containers) + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +**When to use cross-functional tables vs flat swimlanes:** +- Flat swimlanes — one-dimensional (actors only, or phases only). Simpler. Use this when you just need to show who does what in sequence. +- Cross-functional table — two-dimensional (actors AND phases). Use this when **both** the actor and the process stage matter, and every step belongs to a specific (actor, phase) cell. + +**Do NOT** nest swimlanes inside a table row, do NOT set `startSize` on rows or cells (columns tile from `x=0`), and do NOT rely on the AI to produce exact widths that sum to the table width — close-enough totals are fine, the `tableLayout` normalizes them. + +## Layers + +Layers control visibility and z-order. Every cell belongs to exactly one layer. Use layers to manage diagram complexity — viewers can toggle layer visibility to show or hide groups of elements (e.g., "Physical Infrastructure" vs "Logical Network" vs "Security Zones"). + +Cell `id="0"` is the root and cell `id="1"` is the default layer — both always exist. Additional layers are `mxCell` elements with `parent="0"`: + +```xml + + + + + + + + + + + + + +``` + +- A layer is an `mxCell` with `parent="0"` and no `vertex` or `edge` attribute +- Assign shapes to a layer by setting `parent` to the layer's id +- Later layers render on top of earlier layers (higher z-order) +- Add `visible="0"` as an attribute on the layer cell to hide it by default +- Use layers when the diagram has distinct conceptual groupings that viewers may want to toggle independently + +## Tags + +Tags are visual filters that let viewers show or hide elements by category. Unlike layers, a single element can have multiple tags, making tags ideal for cross-cutting concerns (e.g., tagging shapes as "critical", "v2", or "backend"). + +Tags require wrapping `mxCell` in an `` element. Tags are assigned via the `tags` attribute as a space-separated string: + +```xml + + + + + + + + + + + + + + + + +``` + +- Tags require the `` wrapper — a plain `mxCell` cannot have tags +- The `label` attribute on `` replaces `value` on `mxCell` +- Tags are space-separated in the `tags` attribute +- Viewers filter the diagram by selecting tags in the draw.io UI (Edit > Tags) +- Tags do not affect z-order or structural grouping — they are purely a visibility filter + +## Metadata and placeholders + +Metadata stores custom key-value properties on shapes as additional attributes on the `` wrapper element. Combined with placeholders, metadata values can be displayed in labels — useful for data-driven diagrams showing status, owner, IP addresses, or versions on each shape. + +Set `placeholders="1"` on the `` to enable `%propertyName%` substitution in the `label`: + +```xml + + + + + + + + + + + +``` + +- Custom properties are plain XML attributes on `` (e.g., `component="Auth Service"`) +- Set `placeholders="1"` to enable `%key%` substitution in the label and tooltip +- The label must use `html=1` style when using HTML formatting with placeholders +- Placeholders resolve by walking up the containment hierarchy: shape attributes first, then parent container, then layer, then root — first match wins +- Predefined placeholders work without custom properties: `%id%`, `%width%`, `%height%`, `%date%`, `%time%`, `%timestamp%`, `%page%`, `%pagenumber%`, `%pagecount%`, `%filename%` +- Use `%%` for a literal percent sign in labels +- Tags, metadata, and placeholders can all be combined on the same `` element +- Use metadata when shapes represent data records (servers, services, components) and you want to attach structured information beyond the visible label + +## Dark mode colors + +draw.io supports automatic dark mode rendering. How colors behave depends on the property: + +- **`strokeColor`, `fillColor`, `fontColor`** default to `"default"`, which renders as black in light theme and white in dark theme. When no explicit color is set, colors adapt automatically. +- **Explicit colors** (e.g. `fillColor=#DAE8FC`) specify the light-mode color. The dark-mode color is computed automatically by inverting the RGB values (blending toward the inverse at 93%) and rotating the hue by 180° (via `mxUtils.getInverseColor`). +- **`light-dark()` function** — To specify both colors explicitly, use `light-dark(lightColor,darkColor)` in the style string, e.g. `fontColor=light-dark(#7EA6E0,#FF0000)`. The first argument is used in light mode, the second in dark mode. + +To enable dark mode color adaptation, the `mxGraphModel` element must include `adaptiveColors="auto"`. + +When generating diagrams, you generally do not need to specify dark-mode colors — the automatic inversion handles most cases. Use `light-dark()` only when the automatic inverse color is unsatisfactory. + +## Edge routing & layout passes + +By default, edges are drawn by draw.io's **built-in router**, which is intentionally basic: each edge is a straight line or a simple right-angle path between its endpoints, with **no obstacle avoidance** — a connector runs straight through any shape lying between its `source` and `target`. There is no server-side post-processing. Two **opt-in** passes on `create_diagram` upgrade this; they are independent and combine freely, run client-side after the diagram renders, and the exported XML (copy/clipboard, "Open in draw.io") reflects the final routed result. + +- **`routing: "libavoid"`** (XML only) — obstacle-avoiding orthogonal **edge routing**. Vertices stay exactly where you placed them; only the connectors are recomputed, so they run in clean right-angle segments that route *around* the boxes (and spread apart when parallel) instead of cutting across them. Use it for diagrams you laid out deliberately — architecture, network topology, deployment, swimlanes, UML, floor plans — where you want tidy wires without disturbing your layout. +- **`postLayout: "elk"`** — a **full re-layout** (ELK `layered` flow). Vertices animate (morph) from your positions to canonical hierarchical positions, and the edges are routed as part of that. Best for flowcharts, process/state diagrams, decision flows, pipelines, and other directional/hierarchical diagrams. (You should rarely hand-write these as XML — prefer Mermaid.) Flow **direction**: on XML set the optional `direction` field (`"vertical"` (default) / `"horizontal"`); on Mermaid it is read from the flowchart code (`flowchart TD/TB` vs `LR/RL`) and `direction` is ignored. + +The four combinations: + +| `postLayout` | `routing` | Result | +|---|---|---| +| — | — | basic built-in router (straight / simple right-angle, no obstacle avoidance); your positions kept | +| — | `libavoid` | your positions kept; wires re-routed orthogonally *around* the shapes | +| `elk` | — | ELK places the vertices **and** routes the edges (decent routing built in) | +| `elk` | `libavoid` | rarely worth it — ELK already routes; only add `libavoid` if ELK's routing specifically comes out poor | + +**Pick ONE — they are essentially alternatives, not a stack:** +- **Neither** — fine when connected nodes sit in clear rows/columns with open space between them, so the basic router's straight/right-angle lines won't cross another shape. Simplest and lightest; do this by default for sparse layouts. +- **`routing: "libavoid"`** — keep your hand-placed layout but clean up the wires: use whenever an edge would otherwise cut across a box, or you want consistently clean orthogonal wires routed around shapes (architecture, network topology, deployment, UML, floor plans — anything densely connected). +- **`postLayout: "elk"`** — when you want a canonical re-layout (vertices moved). ELK routes the edges itself as part of the layout, so **do not also set `routing`** — the combination is redundant in almost all cases. Add `direction: "horizontal"` for left-to-right flow. + +**For Mermaid diagrams: see the `postLayout` parameter description for when to set it.** Complex Mermaid flowcharts (≥ ~20 nodes, ≥ 3 decision diamonds, feedback edges, or ≥ 3 endpoints) need `postLayout: "elk"` because the native parser's layout goes cramped or unbalanced past that threshold — the direction follows the flowchart code, so no `direction` is needed. Simple flowcharts and all non-flowchart Mermaid types (sequence, class, ER, sankey, …) need no `postLayout`. + +**When NOT to use (XML):** +- The user has asked for specific positions (swim lanes with exact lanes, architecture diagrams with meaningful spatial arrangement). +- The diagram relies on containers/grouping where spatial layout encodes information. + +## Style reference + +Complete style reference (all shape types, style properties, color palettes, HTML labels, and more): https://github.com/jgraph/drawio-mcp/blob/main/shared/style-reference.md + +XML Schema (XSD): https://github.com/jgraph/drawio-mcp/blob/main/shared/mxfile.xsd + +## CRITICAL: XML well-formedness + +When generating draw.io XML, the output **must** be well-formed XML: +- **NEVER include ANY XML comments (``) in the output.** XML comments are strictly forbidden — they waste tokens, can cause parse errors, and serve no purpose in diagram XML. +- Escape special characters in attribute values: `&`, `<`, `>`, `"` +- Always use unique `id` values for each `mxCell` diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..44e9089 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,666 @@ +--- +name: drawio-main +description: Always use when user asks to create, generate, draw, or design a diagram, flowchart, architecture diagram, ER diagram, sequence diagram, class diagram, network diagram, mockup, wireframe, or UI sketch, or mentions draw.io, drawio, drawoi, .drawio files, or diagram export to PNG/SVG/PDF. +--- + +# Draw.io Diagram Skill + +This skill covers two capabilities: + +1. **Diagram generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements +2. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes + +**Skill files** (read these when using this skill): +- [SKILL-CAPABILITIES.md](./SKILL-CAPABILITIES.md) — full list of capabilities and all CLI analysis actions an agent can execute +- [SKILL-RULES-LAYOUT.md](./SKILL-RULES-LAYOUT.md) — mandatory layer model, connector-crossing avoidance, waypoint patterns, overlap verification, layout checklist +- [SKILL-RULES-STYLE.md](./SKILL-RULES-STYLE.md) — mandatory visual appearance and sizing rules (colors, dimensions, connector dash styles, shape sizing) +- [SKILL-XML-REFERENCES.md](./SKILL-XML-REFERENCES.md) — complete draw.io XML reference (styles, routing, containers, layers, dark mode, well-formedness) +- [SKILL-NEGATIVE-SPACE-DIAGRAM.md](./SKILL-NEGATIVE-SPACE-DIAGRAM.md) — rules for generating negative space companion diagrams from `page-negative-space-summary` output + +--- + +Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, SVG, or PDF with the diagram XML embedded (so the exported file remains editable in draw.io). + +## How to create a diagram + +1. **Generate draw.io XML** in mxGraphModel format for the requested diagram +2. **Write the XML** to a `.drawio` file in the current working directory using the Write tool +3. **If the user requested an export format** (png, svg, pdf), locate the draw.io CLI (see below), export with `--embed-diagram`, then delete the source `.drawio` file. If the CLI is not found, keep the `.drawio` file and tell the user they can install the draw.io desktop app to enable export, or open the `.drawio` file directly +4. **Open the result** — the exported file if exported, or the `.drawio` file otherwise. If the open command fails, print the file path so the user can open it manually + +## Choosing the output format + +Check the user's request for a format preference. Examples: + +- `/drawio create a flowchart` → `flowchart.drawio` +- `/drawio png flowchart for login` → `login-flow.drawio.png` +- `/drawio svg: ER diagram` → `er-diagram.drawio.svg` +- `/drawio pdf architecture overview` → `architecture-overview.drawio.pdf` + +If no format is mentioned, just write the `.drawio` file and open it in draw.io. The user can always ask to export later. + +### Supported export formats + +| Format | Embed XML | Notes | +|--------|-----------|-------| +| `png` | Yes (`-e`) | Viewable everywhere, editable in draw.io | +| `svg` | Yes (`-e`) | Scalable, editable in draw.io | +| `pdf` | Yes (`-e`) | Printable, editable in draw.io | +| `jpg` | No | Lossy, no embedded XML support | + +PNG, SVG, and PDF all support `--embed-diagram` — the exported file contains the full diagram XML, so opening it in draw.io recovers the editable diagram. + +## draw.io CLI + +The draw.io desktop app includes a command-line interface for exporting. + +### Locating the CLI + +First, detect the environment, then locate the CLI accordingly: + +#### WSL2 (Windows Subsystem for Linux) + +WSL2 is detected when `/proc/version` contains `microsoft` or `WSL`: + +```bash +grep -qi microsoft /proc/version 2>/dev/null && echo "WSL2" +``` + +On WSL2, use the Windows draw.io Desktop executable via `/mnt/c/...`: + +```bash +DRAWIO_CMD=`/mnt/c/Program Files/draw.io/draw.io.exe` +``` + +The backtick quoting is required to handle the space in `Program Files` in bash. + +If draw.io is installed in a non-default location, check common alternatives: + +```bash +# Default install path +`/mnt/c/Program Files/draw.io/draw.io.exe` + +# Per-user install (if the above does not exist) +`/mnt/c/Users/$WIN_USER/AppData/Local/Programs/draw.io/draw.io.exe` +``` + +#### macOS + +```bash +/Applications/draw.io.app/Contents/MacOS/draw.io +``` + +#### Linux (native) + +```bash +drawio # typically on PATH via snap/apt/flatpak +``` + +#### Windows (native, non-WSL2) + +``` +"C:\Program Files\draw.io\draw.io.exe" +``` + +Use `which drawio` (or `where draw.io` on Windows) to check if it's on PATH before falling back to the platform-specific path. + +### Export command + +```bash +drawio -x -f -e -b 10 -o +``` + +**WSL2 example:** + +```bash +`/mnt/c/Program Files/draw.io/draw.io.exe` -x -f png -e -b 10 -o diagram.drawio.png diagram.drawio +``` + +Key flags: +- `-x` / `--export`: export mode +- `-f` / `--format`: output format (png, svg, pdf, jpg) +- `-e` / `--embed-diagram`: embed diagram XML in the output (PNG, SVG, PDF only) +- `-o` / `--output`: output file path +- `-b` / `--border`: border width around diagram (default: 0) +- `-t` / `--transparent`: transparent background (PNG only) +- `-s` / `--scale`: scale the diagram size +- `--width` / `--height`: fit into specified dimensions (preserves aspect ratio) +- `-a` / `--all-pages`: export all pages (PDF only) +- `-p` / `--page-index`: select a specific page (1-based) + +### Opening the result + +| Environment | Command | +|-------------|---------| +| macOS | `open ` | +| Linux (native) | `xdg-open ` | +| WSL2 | `cmd.exe /c start "" "$(wslpath -w )"` | +| Windows | `start ` | + +**WSL2 notes:** +- `wslpath -w ` converts a WSL2 path (e.g. `/home/user/diagram.drawio`) to a Windows path (e.g. `C:\Users\...`). This is required because `cmd.exe` cannot resolve `/mnt/c/...` style paths. +- The empty string `""` after `start` is required to prevent `start` from interpreting the filename as a window title. + +**WSL2 example:** + +```bash +cmd.exe /c start "" "$(wslpath -w diagram.drawio)" +``` + +## File naming + +- Use a descriptive filename based on the diagram content (e.g., `login-flow`, `database-schema`) +- Use lowercase with hyphens for multi-word names +- For export, use double extensions: `name.drawio.png`, `name.drawio.svg`, `name.drawio.pdf` — this signals the file contains embedded diagram XML +- After a successful export, delete the intermediate `.drawio` file — the exported file contains the full diagram + +## XML format + +A `.drawio` file is native mxGraphModel XML. Always generate XML directly — Mermaid and CSV formats require server-side conversion and cannot be saved as native files. + +### Basic structure + +Every diagram must have this structure: + +```xml + + + + + + + +``` + +- Cell `id="0"` is the root layer +- Cell `id="1"` is the default parent layer +- All diagram elements use `parent="1"` unless using multiple layers + +## XML reference + +For the complete draw.io XML reference including common styles, edge routing, containers, layers, tags, metadata, dark mode colors, and XML well-formedness rules, fetch and follow the instructions at: +https://raw.githubusercontent.com/jgraph/drawio-mcp/main/shared/xml-reference.md + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| draw.io CLI not found | Desktop app not installed or not on PATH | Keep the `.drawio` file and tell the user to install the draw.io desktop app, or open the file manually | +| Export produces empty/corrupt file | Invalid XML (e.g. double hyphens in comments, unescaped special characters) | Validate XML well-formedness before writing; see the XML well-formedness section below | +| Diagram opens but looks blank | Missing root cells `id="0"` and `id="1"` | Ensure the basic mxGraphModel structure is complete | +| Edges not rendering | Edge mxCell is self-closing (no child mxGeometry element) | Every edge must have `` as a child element | +| File won't open after export | Incorrect file path or missing file association | Print the absolute file path so the user can open it manually | + +## CRITICAL: XML well-formedness + +- **NEVER include ANY XML comments (``) in the output.** XML comments are strictly forbidden — they waste tokens, can cause parse errors, and serve no purpose in diagram XML. +- Escape special characters in attribute values: `&`, `<`, `>`, `"` +- Always use unique `id` values for each `mxCell` + + +## Additional points + +- Always use a __10pt grid__. +- Align all elements to the grid; avoid freehand / off-grid placement. +- Use page dimensions that fully fit the diagram, including margins, legends, and connector routing corridors. +- Keep every main shape's `x`, `y`, `width`, and `height` aligned to clean grid increments. +- Prefer element widths and heights divisible by `40`; if a canonical vendor icon has a fixed non-divisible size, wrap it inside a grid-aligned card/container. +- Leave intentional whitespace corridors between columns and rows for connectors. + +### Same-level shape spacing and parent size rules + +**Rule: Same-level siblings must be placed as close as possible while maintaining grid-aligned gaps.** + +Shapes at the same hierarchy level (siblings inside the same parent, or all top-level shapes) must: + +1. **Minimise the gap** between each other — pack them tightly, leaving only enough space for connectors to pass through. +2. **Gap must be grid-aligned** — both the `x` (horizontal gap) and `y` (vertical gap) distances between adjacent same-level shapes must be **divisible by 40 pt**. + +| Gap type | Minimum recommended | Must be divisible by | +|---|---|---| +| Horizontal gap between siblings (x-axis) | 40 pt | 40 pt | +| Vertical gap between siblings (y-axis) | 40 pt | 40 pt | + +> **Example:** If shape A ends at `x=320` and shape B starts at `x=360`, the gap is 40 pt (1 grid unit). If shape A ends at `x=320` and the gap must be wider for a connector corridor, use `x=400` (gap = 80 pt, divisible by 40) — never `x=350` (gap = 30 pt, not on a 40 pt boundary). + +**Rule: A parent (container) shape must always be large enough to fully enclose all its children, including inner padding.** + +When children grow (new children added, children resized, or children repositioned), the parent container must expand to accommodate them. Apply the following sizing formula: + +``` +parent.width = max_child_right + right_padding (right_padding ≥ 40, divisible by 40) +parent.height = max_child_bottom + bottom_padding (bottom_padding ≥ 40, divisible by 40) +``` + +Where: +- `max_child_right = max(child.x + child.width)` over all children (relative to parent) +- `max_child_bottom = max(child.y + child.height)` over all children (relative to parent) +- Left/top inner padding (the space between parent top-left and first child) must also be ≥ 40 pt and divisible by 40 pt. + +**Cascade rule:** Parent expansion may cause *its* parent to also need expansion. Walk up the containment tree and resize each ancestor in turn until the outermost container fits all descendants. + +**Positioning rule after expansion:** When a parent container grows, re-check sibling spacing at every affected level. All gaps must remain divisible by 40 pt after the resize. + +- Avoid routing connectors through shapes, cards, labels, icons, legends, or containers containing important content. +- Prefer orthogonal connectors with explicit waypoints when auto-routing causes overlaps. +- If connector auto-routing crosses shapes, use absolute routed points or fixed waypoints instead of relying on `source` / `target` auto-routing. +- Keep connector labels off the connector path when diagrams are dense. +- If connector labels are needed, place them in a dedicated legend, side note, or clearly empty corridor. +- Do not allow connector labels to overlap shapes, cards, icons, or other text. +- Avoid long text inside narrow shapes; use wider cards or wrap labels in a dedicated text area inside the card. +- Ensure service names fit within the visual card width and do not extend beyond card boundaries. +- For service-card diagrams, prefer a consistent card pattern: icon on the left, service label on the right, enough padding around both. +- Avoid overlapping shapes, including external actors, cards, icons, notes, legends, and containers. +- When using canonical vendor icons, preserve the original icon styling; adjust surrounding card/container layout instead of modifying the icon style. +- For rectangles and containers, do not use rounding unless the style explicitly requires it. +- Avoid borders on shapes unless they are needed for visual separation or canonical styling. +- Use at most __3 primary color families__; create hierarchy with lighter/darker shades instead of adding many unrelated colors. +- Keep connector colors simple and consistent; use dashed lines only for semantically different flows such as admin, private, async, or backup paths. +- Keep arrowheads outside shape interiors; connectors should touch shape/card edges, not pass through the body. +- Validate that all draw.io XML is well-formed and contains required root cells `0` and `1`. +- Ensure all `mxCell` IDs are unique. +- Every edge must include an `mxGeometry` child; use waypoint arrays for routed connectors. +- For complex diagrams, validate connector paths against shape bounding boxes before finalizing. +- Prefer a separate legend for flow explanations when connector labels would clutter the diagram. +- Keep legends outside the main routing area so connectors do not cross legend text. +- Use consistent spacing between diagram lanes/columns and between rows of cards. +- Keep diagram complexity medium by grouping services into logical zones/layers instead of scattering many unrelated services. +- Use swimlanes or containers for major domains, but avoid placing dense connector routes directly through container titles. +- Before completion, re-open or visually inspect the diagram and check specifically for: + - shape overlap + - connector overlap with cards/icons + - connector label overlap + - text overflowing card boundaries + - page clipping + - inconsistent spacing + - missing canonical vendor shapes + +--- + +## Connector Routing Best Practices (Zero-Overlap Guarantee) + +Complex architecture diagrams with many connectors require deliberate routing. Follow this process to achieve zero connector-shape overlaps. + +### 1. Plan corridors before drawing + +Before placing any connectors, identify **clear corridors** — horizontal or vertical bands on the canvas that are free of all shapes. Route every connector through these corridors. + +#### Vertical corridors (x-axis lanes between columns) + +Between each column of shapes, choose a single x-coordinate that lies in the gap: + +``` +GAP = (right_edge_of_left_column + left_edge_of_right_column) / 2 +``` + +Examples from a typical IBM Cloud deployment diagram: + +| Corridor name | x value | Description | +|---------------|---------|-------------| +| GAP_AB | 370 | Between Internet zone and IBM Cloud boundary | +| GAP_BC | 720 | Between IBM edge services and VPC | +| GAP_NS | 1510 | Between namespace columns inside VPC | +| GAP_CD | 2450 | Between VPC right edge and external service group | +| GAP_DE | 2760 | Between two external service columns | + +#### Horizontal corridors (y-axis lanes between rows) + +Between each row of shapes, choose a y-coordinate in the clear gap: + +| Corridor name | y value | Description | +|---------------|---------|-------------| +| Y_TOP | 110 | Above all shapes (highway for cross-diagram connectors) | +| Y_ROKS | 350 | Between ingress row and pod row | +| Y_GW_CORE | 640 | Between gateway namespace and core namespace | +| Y_CICD | 875 | Between ops pods and CI/CD row | +| Y_BETWEEN | 985 | Between CI/CD and database row | +| Y_BELOW | 1200 | Below all shapes | + +**Rule:** When two rows are only 20–40 px apart, the corridor between them is still usable — just ensure the chosen y does not touch any shape's bounding box (see tolerance rule below). + +### 2. Calculate absolute canvas coordinates + +draw.io uses **relative coordinates** for nested containers: a child's `x`/`y` is relative to its parent container's top-left corner, *not* to the canvas. To verify that a connector waypoint (which uses canvas-absolute coordinates) clears a shape, compute: + +``` +abs_x = shape.x + parent.x + grandparent.x + ... (sum all ancestor x offsets) +abs_y = shape.y + parent.y + grandparent.y + ... (sum all ancestor y offsets) +``` + +For swimlane containers, child coordinates are relative to the swimlane's **top-left origin** (not to the content area below the header). `startSize` only affects the visual header height — ignore it when computing absolute coordinates. + +Build a flat list of all shapes with absolute bounding boxes before routing: + +```python +shapes = [ + # (id, abs_x1, abs_y1, abs_x2, abs_y2) + ("pod-api", 985, 416, 1145, 574), + ("pod-svc", 810, 416, 967, 574), + ... +] +``` + +### 3. Verify routes with Python before writing XML + +Run an overlap-detection simulation against all shapes before finalising the XML. This prevents invisible bugs that only become apparent when the file is opened. + +```python +TOL = 2 # pixels — allow connectors to touch but not overlap interiors + +def segment_overlaps_shape(seg, shape): + sx1, sy1, sx2, sy2 = shape + if seg['type'] == 'H': # horizontal segment at y=Y from xA to xB + Y, xA, xB = seg['y'], min(seg['x1'], seg['x2']), max(seg['x1'], seg['x2']) + if sy1 + TOL < Y < sy2 - TOL: + if max(xA, sx1 + TOL) < min(xB, sx2 - TOL): + return True + elif seg['type'] == 'V': # vertical segment at x=X from yA to yB + X, yA, yB = seg['x'], min(seg['y1'], seg['y2']), max(seg['y1'], seg['y2']) + if sx1 + TOL < X < sx2 - TOL: + if max(yA, sy1 + TOL) < min(yB, sy2 - TOL): + return True + return False + +def route_overlaps(segments, shapes): + for seg in segments: + for shape in shapes: + if segment_overlaps_shape(seg, shape[1:]): # skip id + return shape[0], seg + return None +``` + +Convert every connector's waypoint list into a series of H/V segments, then call `route_overlaps` for each connector. Fix any reported overlap before writing the XML. + +**Endpoint-touch rule:** A connector that terminates *at* a target shape will appear to overlap that shape in the algorithm because the last segment enters the shape's bounding box. This is expected and correct — exclude the *terminal shape* (source or target) when checking a connector's first and last segments. + +### 4. Common routing patterns + +#### Pattern A — Top highway for long cross-diagram connectors + +Route connectors that must span the full diagram width through `Y_TOP` (well above all shapes): + +``` +source_mid_y → vertical up to Y_TOP → horizontal across to target_col_x → vertical down to target_mid_y +``` + +Waypoints example (mxGraphModel format): + +```xml + + + + +``` + +#### Pattern B — Stacked services (approach from the left) + +When multiple services are stacked vertically in a column (e.g., Watson/AI services), never route a vertical connector *through* the column. Instead, approach each service individually from a horizontal corridor to its left: + +``` +source → vertical to Y_TOP → horizontal to GAP_CD (just left of the column) → horizontal at service_mid_y → connect to service left edge +``` + +Each service in the stack gets its own connector that stops at the gap corridor. draw.io completes the final short horizontal stub automatically. + +```python +for svc_id, svc_abs_y in stacked_services: + svc_mid_y = svc_abs_y + svc_height / 2 + waypoints = [ + (source_mid_x, Y_TOP), # exit source upward + (GAP_CD, Y_TOP), # travel across at top highway + (GAP_CD, svc_mid_y), # descend to service's row + # last waypoint stops at corridor; draw.io connects to service edge + ] +``` + +#### Pattern C — Narrow inter-pod corridors + +When adjacent pods in a row leave only a small gap (e.g., pod_A right=967, pod_B left=985 → 18 px gap), that gap is still a usable vertical corridor: + +``` +corridor_x = (pod_A_right + pod_B_left) / 2 → e.g., 976 +``` + +Use this narrow corridor to route a vertical segment that exits a pod row and connects to shapes above or below: + +``` +pod_interior → horizontal to corridor_x → vertical through gap to target_y → horizontal to target +``` + +#### Pattern D — Right bypass for connections below a dense pod grid + +When many pods span horizontally across the diagram, route connectors that must reach shapes below by going around the right edge of the pod grid: + +``` +source_mid → horizontal right to RIGHT_BYPASS (just right of all pods) → vertical to target_y → horizontal left to target +``` + +Set `RIGHT_BYPASS` to `(rightmost_pod_right + left_edge_of_next_column) / 2`. + +### 5. Waypoint XML format + +Always use the `Array as="points"` form inside `mxGeometry`: + +```xml + + + + + + + + + +``` + +Rules: +- All waypoint coordinates are **canvas-absolute** (not relative to any container). +- Edges/connectors must be children of the **root layer** (`parent="1"`), never children of a container shape. +- The first waypoint is where draw.io exits the source shape's auto-routing; the last waypoint is where it enters the target. +- Stop the last waypoint at the corridor boundary — do not pass a waypoint into the interior of the target shape. draw.io will complete the stub automatically. + +### CRITICAL — Never use port pin constraints on edges + +**DO NOT** add `exitX`, `exitY`, `exitDx`, `exitDy`, `entryX`, `entryY`, `entryDx`, `entryDy` attributes to edge `mxCell` elements. + +| Indicator | Cause | Meaning | +|-----------|-------|---------| +| 🔵 Blue circle at connector endpoint | `source`/`target` IDs set, no port pins | **Correct** — properly connected to shape | +| 🟢 Green circle with white X at connector endpoint | Port pin constraints (`exitX/Y`, `entryX/Y`) present | **Wrong** — draw.io treats endpoint as floating/unlinked | + +Port pin attributes force draw.io to attach the connector to a precise computed point on the shape boundary. When the waypoints don't exactly match that computed point, draw.io renders the endpoint as floating (green X). This breaks visual connectivity even though the `source`/`target` IDs are set. + +**Correct pattern — source/target IDs + waypoints only, NO port pins:** + +```xml + + + + + + + + + + + + + ... + +``` + +draw.io auto-computes the connection point from `source`/`target` shape IDs and the last waypoint direction. Waypoints guide the routing corridor; the actual attachment to the shape is handled automatically. + +**For «include» / «extend» labels:** use real UTF-8 characters `«include»` / `«extend»` in the edge `value` attribute — not XML entities (`«include»`). + +**Single-port rule:** When multiple edges enter the same target shape, all their last waypoints must approach from the **same axis direction** (all from left, all from right, all from below, etc.). Mixed directions cause `singlePortViolation` in `page-connectors-validation`. + +### 6. Corridor-first layout checklist + +Before placing shapes, plan the layout so corridors are naturally available: + +- [ ] Leave ≥ 40 px horizontal gaps between columns of shapes +- [ ] Leave ≥ 30 px vertical gaps between rows of shapes +- [ ] Reserve at least one wide horizontal corridor above all shapes (`Y_TOP`) +- [ ] Reserve at least one wide horizontal corridor below all shapes (`Y_BELOW`) +- [ ] Do not place shapes in the corridor bands — treat corridors as sacred routing lanes +- [ ] For stacked service columns, leave a vertical corridor to their left at `GAP_CD` +- [ ] Run Python overlap verification before finalising; fix every reported overlap +- [ ] Apply endpoint-touch exclusion when the last segment enters the target shape + +--- + +## Swimlane / Multi-Zone Diagram Routing + +When a diagram uses **swimlane zones** (horizontal bands, each a `swimlane` container), apply the following rules in addition to the general patterns above. + +### Zone layout reference + +For a typical layered architecture with `startSize=40` swimlanes and 40pt spacing: + +``` +zone.y ← swimlane top edge +zone.y + 40 ← header bottom (label occupies y..y+40) +zone.y + 40 + 40 = zone.y+80 ← first child row top (rel y=80 inside container) +zone.y + 80 + child_h ← first child row bottom +... +zone.y + height ← swimlane bottom edge +``` + +Inter-zone gaps (between adjacent swimlanes) are clean horizontal corridors — use their midpoint as the H-travel y-coordinate for connectors crossing zone boundaries. + +### Bypass corridors + +Always reserve **two vertical bypass corridors** outside all zones: + +| Corridor | x value | Rule | +|---|---|---| +| LEFT bypass | `zone.x - 20` (e.g. x=60 when zones start at x=80) | All leftward cross-zone connectors | +| RIGHT bypass | `zone.x + zone.width + 20` (e.g. x=1260 when zones end at x=1240) | All rightward cross-zone connectors | + +These bypass corridors run the full canvas height and are free of all shapes. **Every connector that must travel between zones should route through one of these corridors.** + +### Multi-row swimlane: horizontal segment placement + +When a swimlane has **two rows of shapes** (row1 and row2), each row has a y-range. Never place a connector H segment at a y-value that falls inside a row's y-range — that will overlap sibling shapes. + +Use only these safe H corridors inside a multi-row services swimlane: + +| Corridor | y value | When to use | +|---|---|---| +| Inter-row gap | `row1_bottom + (row2_top - row1_bottom)/2` | H travel between row1 and row2 shapes | +| Services-bottom gap | `row2_bottom + (zone_bottom - row2_bottom)/2` | H travel below row2, still inside zone | +| Inter-zone gaps | midpoint of gap between adjacent zones | H travel outside zones | + +**Example** — services-zone: startSize=40, zone.y=500, row1 abs y=580..620, row2 abs y=660..700, zone bottom=740: +- Inter-row gap corridor: **y=640** (midpoint of y=620..660) +- Services-bottom corridor: **y=720** (midpoint of y=700..740) + +### Connector routing rules for multi-row swimlanes + +**Rule 1 — Never route H segments through a row's y-range.** +If a connector must travel horizontally through the services zone, use y=640 (inter-row) or y=720 (services-bottom), never y=580..620 or y=660..700. + +**Rule 2 — When exiting a shape in row2 that has sibling shapes to its right in the same row:** +Do NOT exit from the bottom of the shape and then travel H at y=720 (services-bottom) to the right — this H will cross the V stubs of right-side siblings that exit to the same corridor. + +**Fix options:** +- a) Exit from the **right side** of the shape → immediately go to RIGHT bypass x=1260 → V down/up to target (no H inside zone) +- b) If right-side exit H would cross a sibling shape body: exit right → first waypoint in the **column gap** to the right (e.g. x=1060 for col5/col6 gap) → V down to services-bottom corridor y=720 → H right to RIGHT bypass → V to target + +**Rule 3 — Left bypass for connectors going downward to lower zones.** +When a row2 shape must connect to a shape in a lower zone (messaging, data), route: +``` +shape_bottom → inter-row corridor y=640 → H LEFT to x=LEFT_BYPASS → V down to target zone corridor → H right to target +``` +This ensures the H segment travels in the clear inter-row gap corridor and never crosses sibling shapes. + +**Rule 4 — connectorShapeOverlaps with zone containers are structural and unavoidable.** +Any connector crossing a swimlane zone boundary will be flagged as `connector_shape_overlap` with the zone container. This is expected and acceptable — focus on eliminating `connectorCrossings` (two connectors intersecting each other) and overlaps with **non-container leaf shapes**. + +### Pattern F — Multi-row swimlane fan-out (api-gateway → many services) + +When a shape in an upper zone connects to N shapes spread across multiple columns of a multi-row services swimlane: + +``` +api-gateway_bottom → H to LEFT_BYPASS at inter-zone gap y → V down LEFT_BYPASS → + branch per target: + - col1 (leftmost): H right from LEFT_BYPASS to target_center_x at inter-row gap y + - col2: same, longer H + - col3..colN: same pattern, extending H further right + - for row2 targets: V from inter-row corridor down to row2 top, enter from top +``` + +All H branches travel at the same y (inter-row gap) — they are **parallel**, not crossing. + +### Pattern G — Event bus → services (right bypass fan-in) + +When an event bus (bottom of diagram) connects back up to multiple services: + +``` +event-bus_right → H right to RIGHT_BYPASS → V up → + branch per target: + - service in row1: H left from RIGHT_BYPASS at row1_center_y to target + - service in row2: stop at services-bottom corridor y=720 → H left → V up 20pt to target bottom + - stagger y values slightly (e.g. y=720 for one, y=730 for another) to avoid parallel confusion +``` + +**Critical:** The H segments going LEFT from RIGHT_BYPASS at y=720/730 will cross V stubs of row2 services that exit their bottoms and route DOWN and RIGHT to the right bypass. To avoid these crossings: +- Route those downward connectors via the **LEFT bypass** instead (Pattern F inverse) +- OR ensure the rightward services exit via their **right side** (not bottom), so no V stub exists at y=720..730 + +### Validation workflow for swimlane diagrams + +1. Run `page-connectors-validation` after every edit +2. Fix `connectorCrossings` first — these are always avoidable +3. Fix `connectorShapeOverlaps` with **leaf shapes** (non-containers) — these are overlaps with actual service boxes +4. Accept `connectorShapeOverlaps` with zone containers as structural (unavoidable) +5. Fix `cornerPortViolations`, `headerEdgeViolations`, `singlePortViolations` — all should reach 0 +6. Target: `connectorCrossings=0`, `cornerPortViolations=0`, `headerEdgeViolations=0`, `singlePortViolations=0` + +--- + +## Section 8 — Text-width estimation for negative space calculation + +When computing negative space manually (e.g. generating a negative-space diagram), **shape bounding boxes alone overestimate occupied space**. Text labels only occupy a sub-region within the shape bbox. Use the following formula: + +``` +charWidth = fontSize × 0.6 // avg glyph width for proportional fonts +rawWidth = longestLineCharCount × charWidth +padding = fontSize × 1.0 // horizontal padding (~0.5em each side) +textWidth = rawWidth + padding + +fontStyle modifiers (draw.io bitmask): + bit 0 (value 1) = bold → charWidth × 1.10 + bit 1 (value 2) = italic → charWidth × 1.05 + +textXMin = clamp(shapeCenterX − textWidth/2, shape.xMin, shape.xMax) +textXMax = clamp(shapeCenterX + textWidth/2, shape.xMin, shape.xMax) +textFlankLeft = textXMin − shape.xMin // free space left of text inside bbox +textFlankRight = shape.xMax − textXMax // free space right of text inside bbox +``` + +**When to apply:** +- **Swimlane headers** (`startSize=40` band): header text is centered in the full zone width → use text region for occupied X, flanks are free negative space +- Large container shapes whose children don't fill the full width +- Any shape where `textWidth < shapeWidth` — the flanks inside the bbox are free + +**Example — Marketplace diagram zone headers, fontSize=12 bold (fontStyle=1), center x=660, zone x=80..1240:** + +| Label | longestLine chars | textWidth | textXMin | textXMax | flank each side | +|---|---|---|---|---|---| +| "Client Layer" | 12 | 107 | 607 | 713 | ~527pt | +| "API Gateway / Edge Layer" | 24 | 202 | 559 | 761 | ~479pt | +| "Core Microservices" | 18 | 155 | 583 | 737 | ~503pt | +| "Messaging / Event Bus" | 21 | 178 | 571 | 749 | ~491pt | +| "Data Layer" | 10 | 91 | 615 | 705 | ~535pt | + +**`page-negative-space-summary` action outputs (per shape):** +- `textXMin`, `textXMax`, `textWidth` — estimated text rendering region +- `textFlankLeft`, `textFlankRight` — free flanks inside bbox +- `rows[].textAwareFreeCorridors` — corridors computed using text regions (wider than bbox-based) + +**When drawing a negative-space diagram:** see [SKILL-NEGATIVE-SPACE-DIAGRAM.md](./SKILL-NEGATIVE-SPACE-DIAGRAM.md) for all rules, construction steps, XML pattern, file naming, and validation. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..215e2a8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1401 @@ +{ + "name": "drawio-tools", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drawio-tools", + "version": "0.1.0", + "dependencies": { + "@maxgraph/core": "^0.23.0", + "js-yaml": "^4.1.0", + "jsdom": "^25.0.0", + "pako": "^2.1.0" + }, + "bin": { + "drawio-tools": "dist/cli/commands.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/jsdom": "^21.1.7", + "@types/node": "^22.0.0", + "@types/pako": "^2.0.3", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@maxgraph/core": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@maxgraph/core/-/core-0.23.0.tgz", + "integrity": "sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==", + "license": "Apache-2.0" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..159e984 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "drawio-tools", + "version": "0.1.0", + "description": "CLI tools for draw.io diagram analysis and verification", + "type": "module", + "packageManager": "pnpm@11.9.0", + "bin": { + "drawio-tools": "dist/cli/commands.js" + }, + "scripts": { + "build": "tsc", + "cli": "tsc && node dist/cli/commands.js" + }, + "dependencies": { + "@maxgraph/core": "^0.23.0", + "js-yaml": "^4.1.0", + "jsdom": "^25.0.0", + "pako": "^2.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/jsdom": "^21.1.7", + "@types/node": "^22.0.0", + "@types/pako": "^2.0.3", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7c75cae --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,893 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@maxgraph/core': + specifier: ^0.23.0 + version: 0.23.0 + js-yaml: + specifier: ^4.1.0 + version: 4.3.0 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + pako: + specifier: ^2.1.0 + version: 2.2.0 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + '@types/pako': + specifier: ^2.0.3 + version: 2.0.4 + tsx: + specifier: ^4.19.0 + version: 4.22.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@maxgraph/core@0.23.0': + resolution: {integrity: sha512-/ZbFaMKDJHg3352ANVDct09Rr7k5mLOZO+q3i0Hy2ODQiNvEnbooj4GkwB4Xaozyqfj/Q6JRT+zebdu4WzPyJg==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + pako@2.2.0: + resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@maxgraph/core@0.23.0': {} + + '@types/js-yaml@4.0.9': {} + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 22.20.0 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/pako@2.0.4': {} + + '@types/tough-cookie@4.0.5': {} + + agent-base@7.1.4: {} + + argparse@2.0.1: {} + + asynckit@0.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + is-potential-custom-element-name@1.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + lru-cache@10.4.3: {} + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nwsapi@2.2.24: {} + + pako@2.2.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + punycode@2.3.1: {} + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + symbol-tree@3.2.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/actions/page-connectors-summary/action.ts b/src/actions/page-connectors-summary/action.ts new file mode 100644 index 0000000..6e8ae83 --- /dev/null +++ b/src/actions/page-connectors-summary/action.ts @@ -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 { + 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>(); + const entriesByShape = new Map>(); + + 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[] = []; + 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, + }; +} diff --git a/src/actions/page-connectors-validation/action.ts b/src/actions/page-connectors-validation/action.ts new file mode 100644 index 0000000..e356443 --- /dev/null +++ b/src/actions/page-connectors-validation/action.ts @@ -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 { + const { shapes, edges, graphModelXml } = parseDiagram(filePath); + const routeMap = routeAllEdges(graphModelXml); + + const issues: Record[] = []; + + 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(); + 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>(); + 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>(); + 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, + }; +} diff --git a/src/actions/page-hierarchy-full/action.ts b/src/actions/page-hierarchy-full/action.ts new file mode 100644 index 0000000..01c1e28 --- /dev/null +++ b/src/actions/page-hierarchy-full/action.ts @@ -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 { + 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(); + 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, + }; +} diff --git a/src/actions/page-hierarchy/action.ts b/src/actions/page-hierarchy/action.ts new file mode 100644 index 0000000..0210775 --- /dev/null +++ b/src/actions/page-hierarchy/action.ts @@ -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 { + 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, + }; +} diff --git a/src/actions/page-labels-validation/action.ts b/src/actions/page-labels-validation/action.ts new file mode 100644 index 0000000..b54a93d --- /dev/null +++ b/src/actions/page-labels-validation/action.ts @@ -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 { + const { shapes } = parseDiagram(filePath); + + const issues: Record[] = []; + + // Track label → [ids] for duplicate detection + const labelIndex = new Map(); + + 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, + }; +} diff --git a/src/actions/page-negative-space-summary/action.ts b/src/actions/page-negative-space-summary/action.ts new file mode 100644 index 0000000..26f2422 --- /dev/null +++ b/src/actions/page-negative-space-summary/action.ts @@ -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(/ |/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 { + // --- 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 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(); + 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, + }; +} diff --git a/src/actions/page-orphans/action.ts b/src/actions/page-orphans/action.ts new file mode 100644 index 0000000..da25c1f --- /dev/null +++ b/src/actions/page-orphans/action.ts @@ -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 { + const { shapes, edges } = parseDiagram(filePath); + + // Build set of shape IDs that have at least one edge + const connectedShapeIds = new Set(); + 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[] = []; + + // 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, + }; +} diff --git a/src/actions/page-recommendations/action.ts b/src/actions/page-recommendations/action.ts new file mode 100644 index 0000000..9f8efce --- /dev/null +++ b/src/actions/page-recommendations/action.ts @@ -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 { + const { pageWidth, pageHeight, shapes } = parseDiagram(filePath); + + let bbox: Record; + 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", + }, + }; +} diff --git a/src/actions/page-shape-bbox-validation/action.ts b/src/actions/page-shape-bbox-validation/action.ts new file mode 100644 index 0000000..5e01da9 --- /dev/null +++ b/src/actions/page-shape-bbox-validation/action.ts @@ -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 { + const { shapes } = parseDiagram(filePath); + + // Build ancestor set for a shape (to skip parent-child pairs) + function getAncestors(id: string): Set { + const ancestors = new Set(); + 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[] = []; + const seenPairs = new Set(); + + 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, + }; +} diff --git a/src/actions/page-summary/action.ts b/src/actions/page-summary/action.ts new file mode 100644 index 0000000..b604698 --- /dev/null +++ b/src/actions/page-summary/action.ts @@ -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 (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 { + 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, + }; +} diff --git a/src/actions/summary/action.ts b/src/actions/summary/action.ts new file mode 100644 index 0000000..c9a32a9 --- /dev/null +++ b/src/actions/summary/action.ts @@ -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 { + const allPages = parseAllPages(filePath); + const pages = allPages.map((page) => buildPageSummary(page)); + + return { + action: "summary", + file: filePath, + pageCount: pages.length, + pages, + }; +} diff --git a/src/actions/validate/action.ts b/src/actions/validate/action.ts new file mode 100644 index 0000000..d6027e5 --- /dev/null +++ b/src/actions/validate/action.ts @@ -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 or ) + * - Unescaped special characters in attribute values + * - Base64/deflate encoding errors in the 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(""); + 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 { + const raw = readFileSync(filePath, "utf-8").trim(); + + // Extract pages (support both mxfile wrapper and bare mxGraphModel) + const pages: Array<{ name: string; xml: string }> = []; + + if (/^]*)>([\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 elements found in mxfile"); + } + } else if (/^ r.valid); + + return { + action: "validate", + file: filePath, + summary: { + pages: results.length, + valid: allValid, + invalidPages: results.filter((r) => !r.valid).length, + }, + pages: results, + }; +} diff --git a/src/cli/commands.ts b/src/cli/commands.ts new file mode 100644 index 0000000..c80c1e4 --- /dev/null +++ b/src/cli/commands.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env node +/** + * drawio-tools CLI dispatcher + * + * Usage: + * drawio-tools --file --action [--page ] + * + * 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; +}; + +const ACTIONS: Record Promise> = { + "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 { + 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 --action [--page ] + +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 --action <${Object.keys(ACTIONS).join("|")}> [--page ]\n` + ); +} + +main(); diff --git a/src/services/connector-router/connector-router.ts b/src/services/connector-router/connector-router.ts new file mode 100644 index 0000000..8600e2e --- /dev/null +++ b/src/services/connector-router/connector-router.ts @@ -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 XML for the page + * @returns Map of edge id → array of canvas-absolute routed points + */ +export function routeAllEdges(graphModelXml: string): Map { + const { edgeRoutes } = loadGraphStates(graphModelXml); + const result = new Map(); + 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, + shapeMidpoints?: Map +): 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 []; +} diff --git a/src/services/drawio-parser/page-summary.ts b/src/services/drawio-parser/page-summary.ts new file mode 100644 index 0000000..15c63f2 --- /dev/null +++ b/src/services/drawio-parser/page-summary.ts @@ -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, + }; +} diff --git a/src/services/drawio-parser/parser.ts b/src/services/drawio-parser/parser.ts new file mode 100644 index 0000000..1601f0b --- /dev/null +++ b/src/services/drawio-parser/parser.ts @@ -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 XML files (single page) + * - … wrappers (draw.io desktop format, + * where each 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(""); + const w = dom.window as unknown as Record; + + // Assign globals that maxGraph expects in a browser environment + (globalThis as Record)["window"] = w; + (globalThis as Record)["document"] = dom.window.document; + (globalThis as Record)["DOMParser"] = dom.window.DOMParser; + (globalThis as Record)["XMLSerializer"] = dom.window.XMLSerializer; + (globalThis as Record)["Element"] = dom.window.Element; + (globalThis as Record)["HTMLElement"] = dom.window.HTMLElement; + (globalThis as Record)["Node"] = dom.window.Node; + (globalThis as Record)["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 , or "Page N" + pageWidth: number; + pageHeight: number; + shapes: Map; + edges: Edge[]; + /** Raw 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 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 = /]*)>/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)["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 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(); + 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 blocks from an string +// --------------------------------------------------------------------------- + +interface DiagramBlock { + name: string; + content: string; +} + +function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] { + const blocks: DiagramBlock[] = []; + // Match each ... element + const re = /]*)>([\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 (/^ 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 (/^; // shape id → depth + childrenOf: Map; // parentId → direct children (Shape objects) + maxDepth: number; + totalLevels: number; // maxDepth + 1 — total nesting levels + depthCounts: Record; // depth → count of shapes at that depth +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildTree( + parentId: string, + childrenMap: Map, + shapeLabels: Map, + 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(); + const shapeLabels = new Map(); + 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(); + 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(); + 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 = {}; + for (const n of allNodes) { + depthCounts[n.depth] = (depthCounts[n.depth] ?? 0) + 1; + } + + return { + tree, + allNodes, + depthMap, + childrenOf, + maxDepth, + totalLevels: maxDepth + 1, + depthCounts, + }; +} diff --git a/src/services/maxgraph-loader/dom-polyfill.ts b/src/services/maxgraph-loader/dom-polyfill.ts new file mode 100644 index 0000000..1929365 --- /dev/null +++ b/src/services/maxgraph-loader/dom-polyfill.ts @@ -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; + +if ((globalThis as Record)["document"]) { + // Parser already set up a DOM — re-use the existing window + win = (globalThis as Record)["window"] as Record; +} else { + const dom = new JSDOM( + '', + { pretendToBeVisual: true } + ); + win = dom.window as unknown as Record; + (globalThis as Record)["document"] = win["document"]; + (globalThis as Record)["window"] = win; + (globalThis as Record)["DOMParser"] = win["DOMParser"]; + (globalThis as Record)["XMLSerializer"] = win["XMLSerializer"]; + (globalThis as Record)["HTMLElement"] = win["HTMLElement"]; + (globalThis as Record)["Element"] = win["Element"]; + (globalThis as Record)["Node"] = win["Node"]; + (globalThis as Record)["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; + +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 {}; diff --git a/src/services/maxgraph-loader/graph-loader.ts b/src/services/maxgraph-loader/graph-loader.ts new file mode 100644 index 0000000..682353b --- /dev/null +++ b/src/services/maxgraph-loader/graph-loader.ts @@ -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; + /** Map of vertex cell ID → absolute bounding box */ + vertexBounds: Map; +} + +// --------------------------------------------------------------------------- +// Internal: create a fresh DOM container for each graph instance +// --------------------------------------------------------------------------- + +function createContainer(): HTMLElement { + const dom = new JSDOM( + '
', + { pretendToBeVisual: true } + ); + return dom.window.document.getElementById("g") as HTMLElement; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Load a single 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 ... 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; + + const edgeRoutes = new Map(); + const vertexBounds = new Map(); + + 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 | 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 }; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7bad8a4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}