feat: add deterministic Diagram IR v2 authoring (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-09-03 03:30:22 -07:00
31 changed files with 2108 additions and 98 deletions
+28 -1
View File
@@ -1,6 +1,6 @@
# drawio-main # drawio-main
CLI tools for programmatic analysis and verification of `.drawio` files. TypeScript tools for deterministic YAML-to-Draw.io generation, analysis, and verification.
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. 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.
@@ -37,12 +37,31 @@ cd scripts
task run -- --file="diagram.drawio" --action=<action> [--page <index>] task run -- --file="diagram.drawio" --action=<action> [--page <index>]
# or directly: # or directly:
node dist/cli/commands.js --file diagram.drawio --action <action> [--page <index>] node dist/cli/commands.js --file diagram.drawio --action <action> [--page <index>]
# Build from semantic YAML Diagram IR:
node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio
``` ```
Output is always YAML to stdout. Output is always YAML to stdout.
## Actions ## Actions
### Authoring
#### `build`
Validates a versioned YAML Diagram IR and emits deterministic native Draw.io XML.
- **v1 remains supported** for single-page linear diagrams.
- **v2** adds multiple pages, containers and semantic kinds, explicit geometry, edge waypoints, provenance/properties/extensions, and typed layout options.
- `linear`, `layered`, `tree`, `grid`, and `manual` layouts are supported. Layered/tree layouts use deterministic dependency-aware placement.
- Generated connectors receive deterministic obstacle-aware orthogonal waypoints when a clear route is available.
- Duplicate/reserved IDs, invalid dimensions, unknown parents, and unknown edge endpoints fail before output is written.
See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`.
---
### Inventory ### Inventory
#### `summary` #### `summary`
@@ -84,6 +103,14 @@ Connector types: `directed` (both endpoints connected), `partial` (one missing),
### Validation ### Validation
#### `quality`
Runs objective visual-quality checks across every page: page clipping, likely text overflow, placeholder text, external image URLs, palette spread, page density, and edge density. Findings have explicit `error` or `warning` severity; the action never invents a minimum defect count.
**Output:** `summary` (pages, shapes, edges, errors, warnings), `issues[]`
---
#### `page-connectors-validation` #### `page-connectors-validation`
Detects two categories of layout defects on the first page: Detects two categories of layout defects on the first page:
+19 -13
View File
@@ -13,10 +13,11 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an
# Draw.io Diagram Skill # Draw.io Diagram Skill
This skill covers two capabilities: This skill covers three capabilities:
1. **Diagram generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements 1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing
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 2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements
3. **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
**Reference files** (read these when using this skill): **Reference files** (read these when using this skill):
- [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute - [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute
@@ -39,11 +40,14 @@ task run -- --file="/path/to/diagram.drawio" --action=page-connectors-validation
# Actions: summary, page-summary, page-hierarchy, page-connectors-summary, # Actions: summary, page-summary, page-hierarchy, page-connectors-summary,
# page-connectors-validation, page-labels-validation, page-shape-bbox-validation, # page-connectors-validation, page-labels-validation, page-shape-bbox-validation,
# page-orphans, page-recommendations, page-hierarchy-full, # page-orphans, page-recommendations, page-hierarchy-full,
# page-negative-space-summary, validate # page-negative-space-summary, quality, validate
# Validate a .drawio file (mandatory final gate) # Validate a .drawio file (mandatory final gate)
task validate -- --file="/path/to/diagram.drawio" task validate -- --file="/path/to/diagram.drawio"
# Build a native .drawio file from a YAML Diagram IR
task generate -- --file="/path/to/spec.yaml" --output="/path/to/diagram.drawio"
# Build TypeScript to dist/ # Build TypeScript to dist/
task build task build
``` ```
@@ -56,10 +60,12 @@ Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, S
## How to create a diagram ## How to create a diagram
1. **Generate draw.io XML** in mxGraphModel format for the requested diagram 1. **Prefer YAML Diagram IR for repeatable diagrams** — use v1 for simple single-page diagrams or v2 for multiple pages, semantic kinds, explicit geometry/waypoints, provenance, and deterministic `linear`, `layered`, `tree`, `grid`, or `manual` layout; then run `build`. See `examples/platform-v2.yaml` and `schemas/diagram-ir-v2.schema.json`
2. **Write the XML** to a `.drawio` file in the current working directory using the Write tool 2. **Generate draw.io XML** in mxGraphModel format for the requested diagram
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 3. **Write the XML** to a `.drawio` file in the current working directory using the Write tool
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 4. **Run the mandatory validation actions** against the generated file
5. **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
6. **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 ## Choosing the output format
@@ -102,19 +108,19 @@ grep -qi microsoft /proc/version 2>/dev/null && echo "WSL2"
On WSL2, use the Windows draw.io Desktop executable via `/mnt/c/...`: On WSL2, use the Windows draw.io Desktop executable via `/mnt/c/...`:
```bash ```bash
DRAWIO_CMD=`/mnt/c/Program Files/draw.io/draw.io.exe` 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. Double quotes preserve the executable path containing the space in `Program Files`.
If draw.io is installed in a non-default location, check common alternatives: If draw.io is installed in a non-default location, check common alternatives:
```bash ```bash
# Default install path # Default install path
`/mnt/c/Program Files/draw.io/draw.io.exe` "/mnt/c/Program Files/draw.io/draw.io.exe"
# Per-user install (if the above does not exist) # Per-user install (if the above does not exist)
`/mnt/c/Users/$WIN_USER/AppData/Local/Programs/draw.io/draw.io.exe` "/mnt/c/Users/$WIN_USER/AppData/Local/Programs/draw.io/draw.io.exe"
``` ```
#### macOS #### macOS
@@ -146,7 +152,7 @@ drawio -x -f <format> -e -b 10 -o <output> <input.drawio>
**WSL2 example:** **WSL2 example:**
```bash ```bash
`/mnt/c/Program Files/draw.io/draw.io.exe` -x -f png -e -b 10 -o diagram.drawio.png diagram.drawio "/mnt/c/Program Files/draw.io/draw.io.exe" -x -f png -e -b 10 -o diagram.drawio.png diagram.drawio
``` ```
Key flags: Key flags:
+52
View File
@@ -0,0 +1,52 @@
version: 2
title: Platform views
provenance:
source: drawio-main example
pages:
- id: system
title: System
layout:
type: layered
direction: horizontal
gridSize: 10
nodeGap: 40
layerGap: 80
nodes:
- id: client
label: Client
kind: user
- id: api
label: API
kind: service
- id: database
label: Database
kind: database
edges:
- id: request
source: client
target: api
kind: request
label: HTTPS
- id: query
source: api
target: database
kind: data
label: SQL
- id: deployment
title: Deployment
layout:
type: layered
direction: vertical
gridSize: 10
nodes:
- id: gateway
label: Gateway
kind: service
- id: workload
label: Workload
kind: service
edges:
- id: forwards
source: gateway
target: workload
kind: request
+19
View File
@@ -0,0 +1,19 @@
version: 1
title: Request flow
direction: horizontal
nodes:
- id: client
label: Client
- id: api
label: API
- id: database
label: Database
edges:
- id: request
source: client
target: api
label: HTTPS
- id: query
source: api
target: database
label: SQL
+24 -5
View File
@@ -4,16 +4,28 @@ This file lists all capabilities an agent can use from this skill.
--- ---
## Capability 1 — Diagram generation ## Capability 1 — Deterministic YAML generation
Build native `.drawio` XML from a validated semantic Diagram IR:
```bash
node dist/cli/commands.js --action build --file architecture.yaml --output architecture.drawio
```
IR v1 remains compatible. IR v2 adds multiple pages, containers and semantic kinds, explicit geometry, waypoints, provenance/properties/extensions, and `linear`, `layered`, `tree`, `grid`, or `manual` layout. Dependency-aware placement and obstacle-aware orthogonal routing are deterministic. The builder rejects reserved/duplicate IDs, unknown parents/endpoints, malformed geometry, and unsupported versions before writing output. See `schemas/diagram-ir-v2.schema.json` and `examples/platform-v2.yaml`.
---
## Capability 2 — Direct XML generation
Create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements. 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.md](../SKILL.md) for the generation workflow, XML format, page sizes, element shapes, and well-formedness rules.
See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rules. See [rules-layout.md](./rules-layout.md) for mandatory connector and layout rules.
--- ---
## Capability 2 — Diagram analysis (drawio-tools CLI) ## Capability 3 — Diagram analysis (drawio-tools CLI)
A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files. A **TypeScript / Node.js** CLI tool for programmatic analysis of `.drawio` files.
Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`. Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` directory), or `task run -- --file=… --action=…`.
@@ -22,8 +34,8 @@ Entry point: `node dist/cli/commands.js` (run from the skill's `scripts/` direct
```bash ```bash
cd <skill>/scripts cd <skill>/scripts
node dist/cli/commands.js --file <path-to-file.drawio> --action <action> [--page <index>] node dist/cli/commands.js --file <path> --action <action> [--page <index>] [--output <path>]
# short flags: -f, -a, -p, -h # short flags: -f, -a, -p, -o, -h
``` ```
`--page` selects the diagram tab (0-based, default 0). Ignored by `summary` (processes all pages). `--page` selects the diagram tab (0-based, default 0). Ignored by `summary` (processes all pages).
@@ -31,6 +43,12 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
### Actions ### Actions
#### Authoring
| Action | Scope | Description |
|---|---|---|
| `build` | YAML Diagram IR | Validate and convert a YAML specification to native `.drawio`; requires `--output` |
#### Inventory #### Inventory
| Action | Scope | Description | | Action | Scope | Description |
@@ -46,6 +64,7 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error.
| Action | Scope | Description | | 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 | | `validate` | All pages | **Mandatory final gate** — XML well-formedness + maxGraph compile + sanity check (vertices/edges > 0). Run before finishing any diagram work |
| `quality` | All pages | Objective clipping, overflow, placeholder, external-asset, palette, page-density, and edge-density checks with explicit severity |
| `page-connectors-validation` | Page 0 | Connector-shape overlaps + connector crossings | | `page-connectors-validation` | Page 0 | Connector-shape overlaps + connector crossings |
| `page-labels-validation` | Page 0 | Empty labels, duplicate labels, labels > 80 chars | | `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-shape-bbox-validation` | Page 0 | Non-containment bounding box overlaps between shapes |
+122
View File
@@ -0,0 +1,122 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://gitea.lego-cloud.eu/home-v1-skills-code-agent/drawio-main/raw/branch/main/schemas/diagram-ir-v2.schema.json",
"title": "drawio-main Diagram IR v2",
"type": "object",
"required": ["version", "pages"],
"properties": {
"version": { "const": 2 },
"title": { "type": "string" },
"pages": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/page" }
},
"provenance": { "type": "object" },
"assets": { "type": "object" },
"theme": { "type": "string" },
"properties": { "type": "object" },
"extensions": { "type": "object" }
},
"additionalProperties": false,
"$defs": {
"safeId": {
"type": "string",
"pattern": "^[A-Za-z][A-Za-z0-9_.:-]*$",
"not": { "enum": ["0", "1"] }
},
"point": {
"type": "object",
"required": ["x", "y"],
"properties": {
"x": { "type": "number" },
"y": { "type": "number" }
},
"additionalProperties": false
},
"geometry": {
"type": "object",
"required": ["x", "y", "width", "height"],
"properties": {
"x": { "type": "number" },
"y": { "type": "number" },
"width": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
},
"layout": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["linear", "layered", "tree", "grid", "manual"] },
"direction": { "enum": ["horizontal", "vertical"] },
"gridSize": { "type": "number", "exclusiveMinimum": 0 },
"nodeGap": { "type": "number", "exclusiveMinimum": 0 },
"layerGap": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
},
"node": {
"type": "object",
"required": ["id", "label"],
"properties": {
"id": { "$ref": "#/$defs/safeId" },
"label": { "type": "string", "minLength": 1, "pattern": "\\S" },
"kind": { "type": "string" },
"parentId": { "$ref": "#/$defs/safeId" },
"width": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 },
"geometry": { "$ref": "#/$defs/geometry" },
"style": { "type": "string" },
"properties": { "type": "object" },
"provenance": { "type": "object" },
"extensions": { "type": "object" }
},
"additionalProperties": false
},
"edge": {
"type": "object",
"required": ["id", "source", "target"],
"properties": {
"id": { "$ref": "#/$defs/safeId" },
"source": { "$ref": "#/$defs/safeId" },
"target": { "$ref": "#/$defs/safeId" },
"label": { "type": "string" },
"kind": { "type": "string" },
"style": { "type": "string" },
"waypoints": {
"type": "array",
"items": { "$ref": "#/$defs/point" }
},
"properties": { "type": "object" },
"provenance": { "type": "object" },
"extensions": { "type": "object" }
},
"additionalProperties": false
},
"page": {
"type": "object",
"required": ["id", "title", "nodes", "edges"],
"properties": {
"id": { "$ref": "#/$defs/safeId" },
"title": { "type": "string", "minLength": 1, "pattern": "\\S" },
"nodes": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/node" }
},
"edges": {
"type": "array",
"items": { "$ref": "#/$defs/edge" }
},
"layout": { "$ref": "#/$defs/layout" },
"width": { "type": "number", "exclusiveMinimum": 0 },
"height": { "type": "number", "exclusiveMinimum": 0 },
"properties": { "type": "object" },
"extensions": { "type": "object" }
},
"additionalProperties": false
}
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ tasks:
Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>]
Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation, Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation,
page-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations, page-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations,
page-hierarchy-full, page-negative-space-summary, validate page-hierarchy-full, page-negative-space-summary, quality, validate
cmds: cmds:
- | - |
./.scripts/cli/api/run.sh {{ .CLI_ARGS }} ./.scripts/cli/api/run.sh {{ .CLI_ARGS }}
+10
View File
@@ -28,6 +28,16 @@ tasks:
CLI_ARGS: "{{ .CLI_ARGS }}" CLI_ARGS: "{{ .CLI_ARGS }}"
silent: true silent: true
generate:
desc: |
Build a native .drawio file from a YAML Diagram IR.
Usage: task generate -- --file="spec.yaml" --output="diagram.drawio"
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=build {{ .CLI_ARGS }}"
silent: true
validate: validate:
desc: | desc: |
Validate a .drawio file (XML well-formedness + required structure). Validate a .drawio file (XML well-formedness + required structure).
+2 -1
View File
@@ -9,7 +9,8 @@
}, },
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"cli": "tsc && node dist/cli/commands.js" "cli": "tsc && node dist/cli/commands.js",
"test": "node --import tsx --test \"src/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@maxgraph/core": "^0.23.0", "@maxgraph/core": "^0.23.0",
+103
View File
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { run } from "./action.js";
const VALID_SPEC = `version: 1
title: Login flow
direction: horizontal
nodes:
- id: client
label: Client
- id: api
label: API
edges:
- id: login
source: client
target: api
label: HTTPS
`;
test("build converts a YAML Diagram IR into deterministic native Draw.io", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-test-"));
const input = join(dir, "login.yaml");
const output = join(dir, "login.drawio");
try {
writeFileSync(input, VALID_SPEC, "utf8");
const result = run(input, 0, output);
assert.equal(existsSync(output), true);
assert.deepEqual(result.summary, { nodes: 2, edges: 1, pages: 1 });
const firstXml = readFileSync(output, "utf8");
run(input, 0, output);
assert.equal(readFileSync(output, "utf8"), firstXml);
const [page] = parseAllPages(output);
assert.equal(page.pageName, "Login flow");
assert.deepEqual([...page.shapes.keys()].sort(), ["api", "client"]);
assert.equal(page.edges[0].sourceId, "client");
assert.equal(page.edges[0].targetId, "api");
assert.equal(page.shapes.get("client")?.x, 40);
assert.equal(page.shapes.get("api")?.x, 240);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build expands default page dimensions to fit generated content", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-page-size-test-"));
const input = join(dir, "wide.yaml");
const output = join(dir, "wide.drawio");
try {
const nodes = Array.from({ length: 10 }, (_, index) => ` - { id: node-${index}, label: Node ${index} }`).join("\n");
writeFileSync(input, `version: 1\ndirection: horizontal\nnodes:\n${nodes}\n`, "utf8");
run(input, 0, output);
const [page] = parseAllPages(output);
const right = Math.max(...[...page.shapes.values()].map((shape) => shape.x2));
assert.ok(page.pageWidth >= right + 40);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build rejects duplicate IDs and unknown edge endpoints", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-invalid-test-"));
const input = join(dir, "invalid.yaml");
const output = join(dir, "invalid.drawio");
try {
writeFileSync(input, `version: 1\nnodes:\n - { id: repeated, label: A }\n - { id: repeated, label: B }\nedges:\n - { id: edge, source: repeated, target: missing }\n`, "utf8");
assert.throws(() => run(input, 0, output), /duplicate node id.*unknown target/si);
assert.equal(existsSync(output), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("build serializes every v2 page and honors explicit geometry and waypoints", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-build-v2-test-"));
const input = join(dir, "platform.yaml");
const output = join(dir, "platform.drawio");
try {
writeFileSync(input, `version: 2\ntitle: Platform\ntheme: zinc\nprovenance: { source: test }\nproperties: { audience: engineering }\nextensions: { owner: drawio-main }\npages:\n - id: system\n title: System\n properties: { view: logical }\n layout: { type: manual }\n nodes:\n - id: client\n label: Client\n kind: actor\n properties: { tier: edge }\n provenance: { source: inventory }\n extensions: { critical: true }\n geometry: { x: 40, y: 80, width: 120, height: 60 }\n - id: api\n label: API\n geometry: { x: 300, y: 80, width: 120, height: 60 }\n edges:\n - id: call\n source: client\n target: api\n kind: request\n properties: { protocol: HTTPS }\n waypoints: [{ x: 230, y: 110 }]\n - id: deployment\n title: Deployment\n nodes:\n - { id: server, label: Server }\n edges: []\n`, "utf8");
const result = run(input, 0, output);
assert.deepEqual(result.summary, { nodes: 3, edges: 1, pages: 2 });
const xml = readFileSync(output, "utf8");
assert.match(xml, /data-theme="zinc"/);
assert.match(xml, /data-provenance="\{&quot;source&quot;:&quot;test&quot;\}"/);
assert.match(xml, /data-properties="\{&quot;audience&quot;:&quot;engineering&quot;\}"/);
assert.match(xml, /data-kind="actor"/);
assert.match(xml, /data-properties="\{&quot;tier&quot;:&quot;edge&quot;\}"/);
assert.match(xml, /data-kind="request"/);
assert.match(xml, /data-properties="\{&quot;protocol&quot;:&quot;HTTPS&quot;\}"/);
const pages = parseAllPages(output);
assert.deepEqual(pages.map((page) => page.pageName), ["System", "Deployment"]);
assert.equal(pages[0].shapes.get("api")?.x, 300);
assert.deepEqual(pages[0].edges[0].waypoints, [{ x: 230, y: 110 }]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+26
View File
@@ -0,0 +1,26 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import * as yaml from "js-yaml";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("build requires --output <path.drawio>");
const parsed = yaml.load(readFileSync(filePath, "utf8"), { schema: yaml.JSON_SCHEMA });
const ir = validateDiagramIR(parsed);
const normalized = normalizeDiagramIR(ir);
const xml = diagramIRToDrawio(normalized);
const resolvedOutput = resolve(outputPath);
writeFileSync(resolvedOutput, xml, "utf8");
return {
action: "build",
file: filePath,
output: resolvedOutput,
summary: {
nodes: normalized.pages.reduce((total, page) => total + page.nodes.length, 0),
edges: normalized.pages.reduce((total, page) => total + page.edges.length, 0),
pages: normalized.pages.length,
},
};
}
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-connectors-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
callback(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const CHILDREN_CONNECTED_INSIDE_CONTAINER = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
test("connector validation ignores the shared ancestor container", () => {
withDrawioFile(CHILDREN_CONNECTED_INSIDE_CONTAINER, (filePath) => {
const result = run(filePath);
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
});
});
test("connector validation ignores every endpoint ancestor for cross-container edges", () => {
const diagram = `<mxGraphModel pageWidth="900" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="external" value="External" vertex="1" parent="1"><mxGeometry x="520" y="100" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="child" target="external"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
withDrawioFile(diagram, (filePath) => {
const result = run(filePath);
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
});
});
@@ -205,16 +205,36 @@ export function run(filePath: string): Record<string, unknown> {
edgeSegList.push({ edge, segs, pts }); edgeSegList.push({ edge, segs, pts });
} }
function ancestorIds(shapeId: string | null): Set<string> {
const ancestors = new Set<string>();
const visited = new Set<string>();
let currentId = shapeId ?? undefined;
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const shape = shapes.get(currentId);
if (!shape) break;
currentId = shape.parentId;
if (currentId && shapes.has(currentId)) ancestors.add(currentId);
}
return ancestors;
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// 1. Connector-shape overlaps // 1. Connector-shape overlaps
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
for (const { edge, segs } of edgeSegList) { for (const { edge, segs } of edgeSegList) {
const endpointAncestors = new Set([
...ancestorIds(edge.sourceId),
...ancestorIds(edge.targetId),
]);
for (let i = 0; i < segs.length; i++) { for (let i = 0; i < segs.length; i++) {
const seg = segs[i]; const seg = segs[i];
const isFirst = i === 0; const isFirst = i === 0;
const isLast = i === segs.length - 1; const isLast = i === segs.length - 1;
for (const [shapeId, shape] of shapes) { for (const [shapeId, shape] of shapes) {
if (endpointAncestors.has(shapeId)) continue;
if (isFirst && shapeId === edge.sourceId) continue; if (isFirst && shapeId === edge.sourceId) continue;
if (isLast && shapeId === edge.targetId) continue; if (isLast && shapeId === edge.targetId) continue;
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-orphans-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
callback(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const CONTAINER_WITH_CONNECTED_CHILDREN = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
<mxCell id="0"/><mxCell id="1" parent="0"/>
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
<mxCell id="edge" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
</root></mxGraphModel>`;
test("page-orphans treats a container with connected descendants as connected", () => {
withDrawioFile(CONTAINER_WITH_CONNECTED_CHILDREN, (filePath) => {
const result = run(filePath);
const summary = result.summary as { isolatedShapes: number };
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
assert.equal(summary.isolatedShapes, 0);
assert.equal(issues.some((issue) => issue.type === "isolated_shape" && issue.shapeId === "container"), false);
});
});
+16 -3
View File
@@ -11,11 +11,24 @@ import { parseDiagram } from "../../services/drawio-parser/parser.js";
export function run(filePath: string): Record<string, unknown> { export function run(filePath: string): Record<string, unknown> {
const { shapes, edges } = parseDiagram(filePath); const { shapes, edges } = parseDiagram(filePath);
// Build set of shape IDs that have at least one edge // Build set of shape IDs that have at least one edge. Connectivity propagates
// to ancestor containers because a container represents its descendants.
const connectedShapeIds = new Set<string>(); const connectedShapeIds = new Set<string>();
function markConnectedWithAncestors(shapeId: string): void {
const visited = new Set<string>();
let currentId: string | undefined = shapeId;
while (currentId && !visited.has(currentId)) {
visited.add(currentId);
const shape = shapes.get(currentId);
if (!shape) break;
connectedShapeIds.add(currentId);
currentId = shape.parentId;
}
}
for (const e of edges) { for (const e of edges) {
if (e.sourceId && shapes.has(e.sourceId)) connectedShapeIds.add(e.sourceId); if (e.sourceId && shapes.has(e.sourceId)) markConnectedWithAncestors(e.sourceId);
if (e.targetId && shapes.has(e.targetId)) connectedShapeIds.add(e.targetId); if (e.targetId && shapes.has(e.targetId)) markConnectedWithAncestors(e.targetId);
} }
const issues: Record<string, unknown>[] = []; const issues: Record<string, unknown>[] = [];
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run } from "./action.js";
test("quality reports clipping, text overflow risk, and placeholders", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-test-"));
const file = join(dir, "quality.drawio");
try {
writeFileSync(file, `<mxfile><diagram name="Page"><mxGraphModel pageWidth="300" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="long" value="This label is far too long for this tiny shape" style="fontSize=14;fillColor=#ffffff;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="60" height="20" as="geometry"/></mxCell><mxCell id="outside" value="Outside" style="fillColor=#eeeeee;" vertex="1" parent="1"><mxGeometry x="260" y="160" width="80" height="60" as="geometry"/></mxCell><mxCell id="todo" value="TODO" style="fillColor=#dddddd;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
const result = run(file) as { summary: { errors: number; warnings: number }; issues: Array<{ type: string; severity: string }> };
assert.ok(result.summary.errors >= 1);
assert.ok(result.summary.warnings >= 2);
assert.ok(result.issues.some((issue) => issue.type === "page_clipping"));
assert.ok(result.issues.some((issue) => issue.type === "text_overflow_risk"));
assert.ok(result.issues.some((issue) => issue.type === "placeholder_text"));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("quality skips page-bound checks when legacy diagrams omit page dimensions", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-no-page-test-"));
const file = join(dir, "legacy.drawio");
try {
writeFileSync(file, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="node" value="Node" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const result = run(file) as { issues: Array<{ type: string }> };
assert.equal(result.issues.some((issue) => issue.type === "page_clipping" || issue.type === "connector_page_clipping"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("quality checks connector waypoints and measures density without double-counting nested shapes", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-routing-test-"));
const file = join(dir, "quality.drawio");
try {
writeFileSync(file, `<mxGraphModel pageWidth="200" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="container" value="Container" style="swimlane;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="120" height="120" as="geometry"/></mxCell><mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="0" y="0" width="120" height="120" as="geometry"/></mxCell><mxCell id="target" value="Target" vertex="1" parent="1"><mxGeometry x="150" y="80" width="40" height="40" as="geometry"/></mxCell><mxCell id="edge" edge="1" parent="1" source="child" target="target"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell></root></mxGraphModel>`, "utf8");
const result = run(file) as { issues: Array<{ type: string }> };
assert.ok(result.issues.some((issue) => issue.type === "connector_page_clipping"));
assert.equal(result.issues.some((issue) => issue.type === "page_density"), false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+153
View File
@@ -0,0 +1,153 @@
import { parseAllPages, type Shape } from "../../services/drawio-parser/parser.js";
type Severity = "error" | "warning";
interface QualityIssue {
type: string;
severity: Severity;
pageIndex: number;
pageName: string;
shapeId?: string;
shapeLabel?: string;
edgeId?: string;
details?: Record<string, unknown>;
}
function styleValue(style: string, key: string): string | undefined {
const match = new RegExp(`(?:^|;)${key}=([^;]*)`, "i").exec(style);
return match?.[1];
}
function plainText(value: string): string {
return value.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ").trim();
}
function textOverflowRisk(shape: Shape): { estimatedLines: number; charactersPerLine: number } | null {
const text = plainText(shape.label);
if (!text) return null;
const fontSize = Number(styleValue(shape.style, "fontSize") ?? 12);
const horizontalPadding = Number(styleValue(shape.style, "spacingLeft") ?? 8) + Number(styleValue(shape.style, "spacingRight") ?? 8);
const verticalPadding = Number(styleValue(shape.style, "spacingTop") ?? 4) + Number(styleValue(shape.style, "spacingBottom") ?? 4);
const usableWidth = Math.max(1, shape.width - horizontalPadding);
const usableHeight = Math.max(1, shape.height - verticalPadding);
const charactersPerLine = Math.max(1, Math.floor(usableWidth / (fontSize * 0.58)));
const explicitLines = text.split("\n");
const estimatedLines = explicitLines.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charactersPerLine)), 0);
const requiredHeight = estimatedLines * fontSize * 1.35;
return requiredHeight > usableHeight ? { estimatedLines, charactersPerLine } : null;
}
function unionArea(shapes: Shape[], pageWidth: number, pageHeight: number): number {
const rects = shapes.map((shape) => ({
left: Math.max(0, shape.x),
right: Math.min(pageWidth, shape.x2),
top: Math.max(0, shape.y),
bottom: Math.min(pageHeight, shape.y2),
})).filter((rect) => rect.right > rect.left && rect.bottom > rect.top);
const xs = [...new Set(rects.flatMap((rect) => [rect.left, rect.right]))].sort((a, b) => a - b);
let area = 0;
for (let index = 0; index < xs.length - 1; index += 1) {
const left = xs[index];
const right = xs[index + 1];
const intervals = rects.filter((rect) => rect.left < right && rect.right > left)
.map((rect) => [rect.top, rect.bottom] as const)
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
let covered = 0;
let start: number | undefined;
let end: number | undefined;
for (const [top, bottom] of intervals) {
if (start === undefined) { start = top; end = bottom; continue; }
if (top > end!) { covered += end! - start; start = top; end = bottom; }
else end = Math.max(end!, bottom);
}
if (start !== undefined) covered += end! - start;
area += (right - left) * covered;
}
return area;
}
export function run(filePath: string): Record<string, unknown> {
const pages = parseAllPages(filePath);
const issues: QualityIssue[] = [];
for (const page of pages) {
const fills = new Set<string>();
for (const shape of page.shapes.values()) {
const fill = styleValue(shape.style, "fillColor");
if (fill && fill !== "none") fills.add(fill.toLowerCase());
if (page.pageWidth > 0 && page.pageHeight > 0 && (shape.x < 0 || shape.y < 0 || shape.x2 > page.pageWidth || shape.y2 > page.pageHeight)) {
issues.push({
type: "page_clipping",
severity: "error",
pageIndex: page.pageIndex,
pageName: page.pageName,
shapeId: shape.id,
shapeLabel: shape.label,
details: { bounds: { x: shape.x, y: shape.y, width: shape.width, height: shape.height }, page: { width: page.pageWidth, height: page.pageHeight } },
});
}
const overflow = textOverflowRisk(shape);
if (overflow) {
issues.push({
type: "text_overflow_risk",
severity: "warning",
pageIndex: page.pageIndex,
pageName: page.pageName,
shapeId: shape.id,
shapeLabel: shape.label,
details: overflow,
});
}
if (/\b(?:todo|tbd|placeholder|lorem ipsum)\b/i.test(plainText(shape.label))) {
issues.push({ type: "placeholder_text", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label });
}
const image = styleValue(shape.style, "image");
if (image && /^(?:https?:)?\/\//i.test(image)) {
issues.push({ type: "external_asset", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label, details: { image } });
}
}
if (page.pageWidth > 0 && page.pageHeight > 0) for (const edge of page.edges) {
const outside = edge.waypoints.filter((point) => point.x < 0 || point.y < 0 || point.x > page.pageWidth || point.y > page.pageHeight);
if (outside.length > 0) {
issues.push({
type: "connector_page_clipping",
severity: "error",
pageIndex: page.pageIndex,
pageName: page.pageName,
edgeId: edge.id,
details: { outsideWaypoints: outside, page: { width: page.pageWidth, height: page.pageHeight } },
});
}
}
if (fills.size > 3) {
issues.push({ type: "palette_spread", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { distinctFillColors: fills.size, recommendedMaximum: 3 } });
}
const pageArea = page.pageWidth * page.pageHeight;
const occupiedArea = unionArea([...page.shapes.values()], page.pageWidth, page.pageHeight);
if (pageArea > 0 && occupiedArea / pageArea > 0.7) {
issues.push({ type: "page_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { occupiedRatio: Math.round((occupiedArea / pageArea) * 1000) / 1000, maximum: 0.7 } });
}
if (page.shapes.size > 0 && page.edges.length / page.shapes.size > 3) {
issues.push({ type: "edge_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { edgesPerShape: Math.round((page.edges.length / page.shapes.size) * 100) / 100, maximum: 3 } });
}
}
return {
action: "quality",
file: filePath,
summary: {
pages: pages.length,
shapes: pages.reduce((total, page) => total + page.shapes.size, 0),
edges: pages.reduce((total, page) => total + page.edges.length, 0),
errors: issues.filter((issue) => issue.severity === "error").length,
warnings: issues.filter((issue) => issue.severity === "warning").length,
},
issues,
};
}
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { run as build } from "../build/action.js";
import { run as validate } from "./action.js";
test("validate accepts Draw.io generated with a standard XML declaration", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-validate-test-"));
const spec = join(dir, "spec.yaml");
const diagram = join(dir, "diagram.drawio");
try {
writeFileSync(spec, "version: 1\nnodes:\n - { id: node, label: Node }\n", "utf8");
build(spec, 0, diagram);
const result = validate(diagram);
assert.deepEqual(result.summary, { pages: 1, valid: true, invalidPages: 0 });
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+2 -45
View File
@@ -26,30 +26,9 @@
* - Base64/deflate encoding errors in the <diagram> body * - Base64/deflate encoding errors in the <diagram> body
*/ */
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { inflateRaw } from "pako";
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js"; import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
import { parseAllPages } from "../../services/drawio-parser/parser.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 { interface PageResult {
pageIndex: number; pageIndex: number;
@@ -82,29 +61,7 @@ function checkXmlWellFormed(xml: string): string | null {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function run(filePath: string): Record<string, unknown> { export function run(filePath: string): Record<string, unknown> {
const raw = readFileSync(filePath, "utf-8").trim(); const pages = parseAllPages(filePath).map((page) => ({ name: page.pageName, xml: page.graphModelXml }));
// Extract pages (support both mxfile wrapper and bare mxGraphModel)
const pages: Array<{ name: string; xml: string }> = [];
if (/^<mxfile/i.test(raw)) {
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
let match: RegExpExecArray | null;
let idx = 0;
while ((match = re.exec(raw)) !== null) {
const name = getAttr(match[1], "name") || `Page ${idx + 1}`;
const xml = decodeDiagramContent(match[2].trim());
pages.push({ name, xml });
idx++;
}
if (pages.length === 0) {
throw new Error("No <diagram> elements found in mxfile");
}
} else if (/^<mxGraphModel/i.test(raw)) {
pages.push({ name: "Page 1", xml: raw });
} else {
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
}
const results: PageResult[] = []; const results: PageResult[] = [];
+101
View File
@@ -0,0 +1,101 @@
import type { DiagramEdge, DiagramIR, DiagramNode, DiagramPage } from "../model/diagram-ir.js";
import { normalizeDiagramIR } from "../model/diagram-ir.js";
import { routePageEdges } from "../services/authoring-router/orthogonal-router.js";
import { layoutPage } from "../services/layout/layout-engine.js";
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function stableJson(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function dataAttributes(values: Record<string, unknown>): string {
return Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
.join("");
}
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
return {
x: horizontal ? 40 + index * 200 : 40,
y: horizontal ? 80 : 80 + index * 120,
width: node.width ?? 120,
height: node.height ?? 60,
};
}
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
const geometry = geometryForNode(node, index, page);
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
const parent = node.parentId ?? "1";
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
}
function serializeEdge(edge: DiagramEdge): string {
const points = edge.waypoints?.length
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
: "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
}
function contentExtent(page: DiagramPage): { width: number; height: number } {
const byId = new Map(page.nodes.map((node) => [node.id, node]));
const absolute = new Map<string, { x: number; y: number; width: number; height: number }>();
const resolve = (id: string): { x: number; y: number; width: number; height: number } | undefined => {
const cached = absolute.get(id);
if (cached) return cached;
const node = byId.get(id);
if (!node?.geometry) return undefined;
const parent = node.parentId ? resolve(node.parentId) : undefined;
const geometry = { ...node.geometry, x: node.geometry.x + (parent?.x ?? 0), y: node.geometry.y + (parent?.y ?? 0) };
absolute.set(id, geometry);
return geometry;
};
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
return {
width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)),
height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)),
};
}
function serializePage(input: DiagramPage): string {
const page = routePageEdges(layoutPage(input));
const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
const edgeCells = page.edges.map(serializeEdge);
const extent = contentExtent(page);
const grid = page.layout?.gridSize ?? 10;
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
const normalized = normalizeDiagramIR(ir);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
}
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const CLI = resolve("src/cli/commands.ts");
test("CLI exposes YAML build with an explicit output path", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-build-test-"));
const input = join(dir, "spec.yaml");
const output = join(dir, "diagram.drawio");
try {
writeFileSync(input, "version: 1\nnodes:\n - { id: one, label: One }\n", "utf8");
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--action", "build", "--file", input, "--output", output], {
cwd: resolve("."),
encoding: "utf8",
});
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.match(result.stdout, /action: build/);
assert.equal(existsSync(output), true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("CLI rejects partially numeric page indexes", () => {
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--action", "summary", "--file", "unused.drawio", "--page", "1abc"], {
cwd: resolve("."),
encoding: "utf8",
});
assert.equal(result.status, 1);
assert.match(result.stderr, /non-negative integer/);
});
test("CLI validate exits non-zero when the validation summary is invalid", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
const input = join(dir, "empty.drawio");
try {
writeFileSync(input, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`, "utf8");
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--action", "validate", "--file", input], {
cwd: resolve("."),
encoding: "utf8",
});
assert.equal(result.status, 1, result.stdout + result.stderr);
assert.match(result.stdout, /valid: false/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+18 -7
View File
@@ -28,10 +28,11 @@ import * as yaml from "js-yaml";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
type ActionModule = { type ActionModule = {
run: (filePath: string, pageIndex?: number) => Record<string, unknown>; run: (filePath: string, pageIndex?: number, outputPath?: string) => Record<string, unknown>;
}; };
const ACTIONS: Record<string, () => Promise<ActionModule>> = { const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"build": () => import("../actions/build/action.js"),
"summary": () => import("../actions/summary/action.js"), "summary": () => import("../actions/summary/action.js"),
"page-summary": () => import("../actions/page-summary/action.js"), "page-summary": () => import("../actions/page-summary/action.js"),
"page-hierarchy": () => import("../actions/page-hierarchy/action.js"), "page-hierarchy": () => import("../actions/page-hierarchy/action.js"),
@@ -44,6 +45,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"), "page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"),
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"), "page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"),
"validate": () => import("../actions/validate/action.js"), "validate": () => import("../actions/validate/action.js"),
"quality": () => import("../actions/quality/action.js"),
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -51,7 +53,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function main(): Promise<void> { async function main(): Promise<void> {
let values: { file?: string; action?: string; page?: string; help?: boolean }; let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean };
try { try {
({ values } = parseArgs({ ({ values } = parseArgs({
args: process.argv.slice(2), args: process.argv.slice(2),
@@ -59,6 +61,7 @@ async function main(): Promise<void> {
file: { type: "string", short: "f" }, file: { type: "string", short: "f" },
action: { type: "string", short: "a" }, action: { type: "string", short: "a" },
page: { type: "string", short: "p" }, page: { type: "string", short: "p" },
output: { type: "string", short: "o" },
help: { type: "boolean", short: "h" }, help: { type: "boolean", short: "h" },
}, },
strict: true, strict: true,
@@ -69,7 +72,7 @@ async function main(): Promise<void> {
} }
if (values.help) { if (values.help) {
console.log(`drawio-tools --file <path> --action <action> [--page <index>] console.log(`drawio-tools --file <path> --action <action> [--page <index>] [--output <path>]
Actions: ${Object.keys(ACTIONS).join(", ")}`); Actions: ${Object.keys(ACTIONS).join(", ")}`);
process.exit(0); process.exit(0);
@@ -90,8 +93,12 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
// Parse optional --page argument (0-based index, default 0) // Parse optional --page argument (0-based index, default 0)
let pageIndex = 0; let pageIndex = 0;
if (values.page !== undefined) { if (values.page !== undefined) {
pageIndex = parseInt(values.page, 10); if (!/^\d+$/.test(values.page)) {
if (isNaN(pageIndex) || pageIndex < 0) { printError(`--page must be a non-negative integer, got: ${values.page}`);
process.exit(1);
}
pageIndex = Number(values.page);
if (!Number.isSafeInteger(pageIndex)) {
printError(`--page must be a non-negative integer, got: ${values.page}`); printError(`--page must be a non-negative integer, got: ${values.page}`);
process.exit(1); process.exit(1);
} }
@@ -106,10 +113,14 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
try { try {
const mod = await ACTIONS[actionName](); const mod = await ACTIONS[actionName]();
const result = mod.run(filePath, pageIndex); const result = mod.run(filePath, pageIndex, values.output);
process.stdout.write( process.stdout.write(
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 }) yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
); );
const summary = result.summary;
if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record<string, unknown>).valid === false) {
process.exitCode = 1;
}
} catch (err) { } catch (err) {
const errorOutput = { const errorOutput = {
error: true, error: true,
@@ -126,7 +137,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
function printError(msg: string): void { function printError(msg: string): void {
process.stderr.write(`Error: ${msg}\n`); process.stderr.write(`Error: ${msg}\n`);
process.stderr.write( process.stderr.write(
`Usage: drawio-tools --file <path> --action <${Object.keys(ACTIONS).join("|")}> [--page <index>]\n` `Usage: drawio-tools --file <path> --action <${Object.keys(ACTIONS).join("|")}> [--page <index>] [--output <path>]\n`
); );
} }
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const schemaUrl = new URL("../../../schemas/diagram-ir-v2.schema.json", import.meta.url);
test("published IR v2 schema defines pages, geometry, waypoints, and provenance", () => {
const schema = JSON.parse(readFileSync(schemaUrl, "utf8")) as {
properties: Record<string, unknown>;
$defs: Record<string, unknown>;
};
assert.ok(schema.properties.pages);
assert.ok(schema.properties.provenance);
assert.ok(schema.$defs.geometry);
const geometry = schema.$defs.geometry as { required?: string[]; properties?: Record<string, unknown>; allOf?: unknown };
assert.equal(geometry.allOf, undefined);
assert.deepEqual(geometry.required, ["x", "y", "width", "height"]);
assert.ok(geometry.properties?.width);
assert.ok(schema.$defs.point);
assert.ok(schema.$defs.page);
const page = schema.$defs.page as { properties?: { title?: { pattern?: string } } };
const node = schema.$defs.node as { properties?: { label?: { pattern?: string } } };
assert.equal(page.properties?.title?.pattern, "\\S");
assert.equal(node.properties?.label?.pattern, "\\S");
assert.ok(schema.$defs.edge);
});
+88
View File
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import test from "node:test";
import { normalizeDiagramIR, validateDiagramIR } from "./diagram-ir.js";
test("IR v2 supports pages, containers, geometry, semantics, and waypoints", () => {
const ir = validateDiagramIR({
version: 2,
title: "Platform",
pages: [{
id: "system",
title: "System",
layout: { type: "layered", direction: "horizontal", gridSize: 10 },
nodes: [
{ id: "zone", label: "Application zone", kind: "container", geometry: { x: 20, y: 20, width: 520, height: 240 } },
{ id: "web", label: "Web", kind: "service", parentId: "zone", properties: { tier: "edge" } },
{ id: "api", label: "API", kind: "service", parentId: "zone" },
],
edges: [{ id: "request", source: "web", target: "api", kind: "request", waypoints: [{ x: 200, y: 100 }] }],
}],
provenance: { source: "test" },
});
const normalized = normalizeDiagramIR(ir);
assert.equal(normalized.version, 2);
assert.equal(normalized.pages.length, 1);
assert.equal(normalized.pages[0].nodes[0].kind, "container");
assert.deepEqual(normalized.pages[0].edges[0].waypoints, [{ x: 200, y: 100 }]);
});
test("IR v2 rejects unknown parents, parent cycles, unsupported fields, and reserved IDs", () => {
assert.throws(() => validateDiagramIR({
version: 2,
pages: [{
id: "page",
title: "Page",
nodes: [{ id: "0", label: "Reserved", parentId: "missing" }],
edges: [],
}],
}), /reserved id.*unknown parent/si);
assert.throws(() => validateDiagramIR({
version: 2,
pages: [{
id: "page",
title: "Page",
nodes: [
{ id: "a", label: "A", parentId: "b" },
{ id: "b", label: "B", parentId: "a" },
],
edges: [],
}],
}), /parent cycle/i);
assert.throws(() => validateDiagramIR({
version: 2,
pages: [{
id: "page",
title: "Page",
nodes: [
{ id: "parent", label: "Not a container" },
{ id: "child", label: "Child", parentId: "parent" },
],
edges: [],
}],
}), /parent.*not a container/i);
assert.throws(() => validateDiagramIR({
version: 2,
unexpected: true,
pages: [{ id: "page", title: "Page", nodes: [{ id: "a", label: "A", typo: true }], edges: [] }],
}), /unsupported field/i);
});
test("IR v1 normalizes into a single v2 page without changing stable IDs", () => {
const v1 = validateDiagramIR({
version: 1,
title: "Legacy",
direction: "horizontal",
nodes: [{ id: "client", label: "Client" }, { id: "api", label: "API" }],
edges: [{ id: "call", source: "client", target: "api" }],
});
const normalized = normalizeDiagramIR(v1);
assert.equal(normalized.pages[0].title, "Legacy");
assert.deepEqual(normalized.pages[0].nodes.map((node) => node.id), ["client", "api"]);
assert.equal(normalized.pages[0].edges[0].id, "call");
assert.throws(() => validateDiagramIR({ version: 1, title: 42, nodes: [{ id: "node", label: "Node" }] }), /title must be a string/i);
});
+298
View File
@@ -0,0 +1,298 @@
export type DiagramDirection = "horizontal" | "vertical";
export type DiagramLayoutType = "linear" | "layered" | "tree" | "grid" | "manual";
export interface DiagramPoint {
x: number;
y: number;
}
export interface DiagramGeometry extends DiagramPoint {
width: number;
height: number;
}
export interface DiagramLayout {
type: DiagramLayoutType;
direction?: DiagramDirection;
gridSize?: number;
nodeGap?: number;
layerGap?: number;
}
export interface DiagramNode {
id: string;
label: string;
kind?: string;
parentId?: string;
width?: number;
height?: number;
geometry?: DiagramGeometry;
style?: string;
properties?: Record<string, unknown>;
provenance?: Record<string, unknown>;
extensions?: Record<string, unknown>;
}
export interface DiagramEdge {
id: string;
source: string;
target: string;
label?: string;
kind?: string;
style?: string;
waypoints?: DiagramPoint[];
properties?: Record<string, unknown>;
provenance?: Record<string, unknown>;
extensions?: Record<string, unknown>;
}
export interface DiagramIRV1 {
version: 1;
title?: string;
direction?: DiagramDirection;
nodes: DiagramNode[];
edges?: DiagramEdge[];
}
export interface DiagramPage {
id: string;
title: string;
nodes: DiagramNode[];
edges: DiagramEdge[];
layout?: DiagramLayout;
width?: number;
height?: number;
properties?: Record<string, unknown>;
extensions?: Record<string, unknown>;
}
export interface DiagramIRV2 {
version: 2;
title?: string;
pages: DiagramPage[];
provenance?: Record<string, unknown>;
assets?: Record<string, unknown>;
theme?: string;
properties?: Record<string, unknown>;
extensions?: Record<string, unknown>;
}
export type DiagramIR = DiagramIRV1 | DiagramIRV2;
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_.:-]*$/;
const RESERVED_IDS = new Set(["0", "1"]);
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
const ROOT_V2_FIELDS = new Set(["version", "title", "pages", "provenance", "assets", "theme", "properties", "extensions"]);
const PAGE_FIELDS = new Set(["id", "title", "nodes", "edges", "layout", "width", "height", "properties", "extensions"]);
const NODE_FIELDS = new Set(["id", "label", "kind", "parentId", "width", "height", "geometry", "style", "properties", "provenance", "extensions"]);
const EDGE_FIELDS = new Set(["id", "source", "target", "label", "kind", "style", "waypoints", "properties", "provenance", "extensions"]);
const LAYOUT_FIELDS = new Set(["type", "direction", "gridSize", "nodeGap", "layerGap"]);
function validateAllowedFields(value: Record<string, unknown>, allowed: Set<string>, path: string, errors: string[]): void {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) errors.push(`${path} has unsupported field: ${key}`);
}
}
function validateObjectField(value: Record<string, unknown>, key: string, path: string, errors: string[]): void {
if (value[key] !== undefined && !isObject(value[key])) errors.push(`${path}.${key} must be an object`);
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function validatePositiveNumber(value: unknown, path: string, errors: string[]): void {
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
errors.push(`${path} must be a positive number`);
}
}
function validatePoint(value: unknown, path: string, errors: string[], geometry = false): void {
if (!isObject(value)) {
errors.push(`${path} must be an object`);
return;
}
validateAllowedFields(value, geometry ? new Set(["x", "y", "width", "height"]) : new Set(["x", "y"]), path, errors);
for (const axis of ["x", "y"] as const) {
if (typeof value[axis] !== "number" || !Number.isFinite(value[axis])) {
errors.push(`${path}.${axis} must be a finite number`);
}
}
}
function validatePage(rawPage: unknown, pageIndex: number, errors: string[]): void {
if (!isObject(rawPage)) {
errors.push(`page ${pageIndex} must be an object`);
return;
}
const pageId = typeof rawPage.id === "string" ? rawPage.id : "";
const pagePath = `page ${pageId || pageIndex}`;
validateAllowedFields(rawPage, PAGE_FIELDS, pagePath, errors);
validateObjectField(rawPage, "properties", pagePath, errors);
validateObjectField(rawPage, "extensions", pagePath, errors);
if (!SAFE_ID.test(pageId)) errors.push(`page ${pageIndex} has an invalid id`);
if (RESERVED_IDS.has(pageId)) errors.push(`page ${pageId} uses a reserved id`);
if (typeof rawPage.title !== "string" || rawPage.title.trim() === "") errors.push(`page ${pageId || pageIndex} requires a title`);
if (!Array.isArray(rawPage.nodes) || rawPage.nodes.length === 0) errors.push(`page ${pageId || pageIndex} nodes must be a non-empty array`);
if (!Array.isArray(rawPage.edges)) errors.push(`page ${pageId || pageIndex} edges must be an array`);
if (rawPage.layout !== undefined) {
if (!isObject(rawPage.layout)) {
errors.push(`page ${pageId || pageIndex} layout must be an object`);
} else {
validateAllowedFields(rawPage.layout, LAYOUT_FIELDS, `${pagePath} layout`, errors);
if (!LAYOUT_TYPES.has(rawPage.layout.type as DiagramLayoutType)) errors.push(`page ${pageId || pageIndex} layout.type is unsupported`);
if (rawPage.layout.direction !== undefined && rawPage.layout.direction !== "horizontal" && rawPage.layout.direction !== "vertical") {
errors.push(`page ${pageId || pageIndex} layout.direction must be horizontal or vertical`);
}
validatePositiveNumber(rawPage.layout.gridSize, `page ${pageId || pageIndex} layout.gridSize`, errors);
validatePositiveNumber(rawPage.layout.nodeGap, `page ${pageId || pageIndex} layout.nodeGap`, errors);
validatePositiveNumber(rawPage.layout.layerGap, `page ${pageId || pageIndex} layout.layerGap`, errors);
}
}
validatePositiveNumber(rawPage.width, `page ${pageId || pageIndex} width`, errors);
validatePositiveNumber(rawPage.height, `page ${pageId || pageIndex} height`, errors);
const nodes = Array.isArray(rawPage.nodes) ? rawPage.nodes : [];
const edges = Array.isArray(rawPage.edges) ? rawPage.edges : [];
const ids = new Set<string>();
const nodeIds = new Set<string>();
for (const [index, rawNode] of nodes.entries()) {
if (!isObject(rawNode)) {
errors.push(`page ${pageId || pageIndex} node ${index} must be an object`);
continue;
}
const id = typeof rawNode.id === "string" ? rawNode.id : "";
const nodePath = `node ${id || index}`;
validateAllowedFields(rawNode, NODE_FIELDS, nodePath, errors);
for (const key of ["properties", "provenance", "extensions"]) validateObjectField(rawNode, key, nodePath, errors);
if (!SAFE_ID.test(id)) errors.push(`node ${index} has an invalid id`);
if (RESERVED_IDS.has(id)) errors.push(`node ${id} uses a reserved id`);
if (ids.has(id)) errors.push(`duplicate node id: ${id}`);
if (id) { ids.add(id); nodeIds.add(id); }
if (typeof rawNode.label !== "string" || rawNode.label.trim() === "") errors.push(`node ${id || index} requires a label`);
for (const key of ["kind", "parentId", "style"]) {
if (rawNode[key] !== undefined && typeof rawNode[key] !== "string") errors.push(`node ${id || index}.${key} must be a string`);
}
validatePositiveNumber(rawNode.width, `node ${id || index} width`, errors);
validatePositiveNumber(rawNode.height, `node ${id || index} height`, errors);
if (rawNode.geometry !== undefined) {
validatePoint(rawNode.geometry, `node ${id || index} geometry`, errors, true);
if (isObject(rawNode.geometry)) {
validatePositiveNumber(rawNode.geometry.width, `node ${id || index} geometry.width`, errors);
validatePositiveNumber(rawNode.geometry.height, `node ${id || index} geometry.height`, errors);
}
}
}
const parentById = new Map<string, string>();
const nodeById = new Map(nodes.filter(isObject).map((node) => [String(node.id), node]));
for (const [index, rawNode] of nodes.entries()) {
if (!isObject(rawNode)) continue;
const id = typeof rawNode.id === "string" ? rawNode.id : String(index);
if (rawNode.parentId !== undefined && (typeof rawNode.parentId !== "string" || !nodeIds.has(rawNode.parentId))) {
errors.push(`node ${id} has unknown parent: ${String(rawNode.parentId)}`);
} else if (typeof rawNode.parentId === "string") {
parentById.set(id, rawNode.parentId);
if (nodeById.get(rawNode.parentId)?.kind !== "container") {
errors.push(`node ${id} parent ${rawNode.parentId} is not a container`);
}
}
if (rawNode.parentId === rawNode.id) errors.push(`node ${id} cannot be its own parent`);
}
for (const start of parentById.keys()) {
const seen = new Set<string>();
let current: string | undefined = start;
while (current !== undefined) {
if (seen.has(current)) {
errors.push(`node ${start} participates in a parent cycle`);
break;
}
seen.add(current);
current = parentById.get(current);
}
}
for (const [index, rawEdge] of edges.entries()) {
if (!isObject(rawEdge)) {
errors.push(`page ${pageId || pageIndex} edge ${index} must be an object`);
continue;
}
const id = typeof rawEdge.id === "string" ? rawEdge.id : "";
const edgePath = `edge ${id || index}`;
validateAllowedFields(rawEdge, EDGE_FIELDS, edgePath, errors);
for (const key of ["properties", "provenance", "extensions"]) validateObjectField(rawEdge, key, edgePath, errors);
if (!SAFE_ID.test(id)) errors.push(`edge ${index} has an invalid id`);
if (RESERVED_IDS.has(id)) errors.push(`edge ${id} uses a reserved id`);
if (ids.has(id)) errors.push(`duplicate edge id: ${id}`);
if (id) ids.add(id);
for (const key of ["source", "target", "label", "kind", "style"]) {
if (rawEdge[key] !== undefined && typeof rawEdge[key] !== "string") errors.push(`edge ${id || index}.${key} must be a string`);
}
if (typeof rawEdge.source !== "string" || !nodeIds.has(rawEdge.source)) errors.push(`edge ${id || index} has unknown source: ${String(rawEdge.source)}`);
if (typeof rawEdge.target !== "string" || !nodeIds.has(rawEdge.target)) errors.push(`edge ${id || index} has unknown target: ${String(rawEdge.target)}`);
if (rawEdge.waypoints !== undefined) {
if (!Array.isArray(rawEdge.waypoints)) errors.push(`edge ${id || index} waypoints must be an array`);
else rawEdge.waypoints.forEach((point, pointIndex) => validatePoint(point, `edge ${id || index} waypoint ${pointIndex}`, errors));
}
}
}
export function validateDiagramIR(value: unknown): DiagramIR {
const errors: string[] = [];
if (!isObject(value)) throw new Error("Diagram IR must be a YAML object");
if (value.version === 1) {
validateAllowedFields(value, ROOT_V1_FIELDS, "root", errors);
if (value.title !== undefined && typeof value.title !== "string") errors.push("title must be a string");
if (!Array.isArray(value.nodes) || value.nodes.length === 0) errors.push("nodes must be a non-empty array");
if (value.edges !== undefined && !Array.isArray(value.edges)) errors.push("edges must be an array");
if (value.direction !== undefined && value.direction !== "horizontal" && value.direction !== "vertical") {
errors.push("direction must be horizontal or vertical");
}
validatePage({ id: "page-1", title: typeof value.title === "string" && value.title.trim() ? value.title : "Page 1", nodes: value.nodes, edges: value.edges ?? [], layout: { type: "linear", direction: value.direction ?? "horizontal" } }, 0, errors);
} else if (value.version === 2) {
validateAllowedFields(value, ROOT_V2_FIELDS, "root", errors);
if (value.title !== undefined && typeof value.title !== "string") errors.push("root.title must be a string");
if (value.theme !== undefined && typeof value.theme !== "string") errors.push("root.theme must be a string");
for (const key of ["provenance", "assets", "properties", "extensions"]) validateObjectField(value, key, "root", errors);
if (!Array.isArray(value.pages) || value.pages.length === 0) errors.push("pages must be a non-empty array");
const pageIds = new Set<string>();
for (const [index, page] of (Array.isArray(value.pages) ? value.pages : []).entries()) {
validatePage(page, index, errors);
if (isObject(page) && typeof page.id === "string") {
if (pageIds.has(page.id)) errors.push(`duplicate page id: ${page.id}`);
pageIds.add(page.id);
}
}
} else {
errors.push("version must be 1 or 2");
}
if (errors.length > 0) throw new Error(`Invalid Diagram IR:\n- ${errors.join("\n- ")}`);
return value as unknown as DiagramIR;
}
export function normalizeDiagramIR(ir: DiagramIR): DiagramIRV2 {
if (ir.version === 2) return ir;
return {
version: 2,
title: ir.title,
pages: [{
id: "page-1",
title: ir.title ?? "Page 1",
nodes: ir.nodes,
edges: ir.edges ?? [],
layout: {
type: "linear",
direction: ir.direction ?? "horizontal",
gridSize: 10,
nodeGap: (ir.direction ?? "horizontal") === "horizontal" ? 80 : 60,
},
}],
};
}
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js";
import { routePageEdges } from "./orthogonal-router.js";
function segmentCrossesRect(a: DiagramPoint, b: DiagramPoint, rect: DiagramGeometry): boolean {
if (a.y === b.y) {
return a.y > rect.y && a.y < rect.y + rect.height && Math.max(Math.min(a.x, b.x), rect.x) < Math.min(Math.max(a.x, b.x), rect.x + rect.width);
}
if (a.x === b.x) {
return a.x > rect.x && a.x < rect.x + rect.width && Math.max(Math.min(a.y, b.y), rect.y) < Math.min(Math.max(a.y, b.y), rect.y + rect.height);
}
return true;
}
function segmentAlongRectEdge(a: DiagramPoint, b: DiagramPoint, rect: DiagramGeometry): boolean {
const verticalBoundary = a.x === b.x && (a.x === rect.x || a.x === rect.x + rect.width) && Math.max(Math.min(a.y, b.y), rect.y) < Math.min(Math.max(a.y, b.y), rect.y + rect.height);
const horizontalBoundary = a.y === b.y && (a.y === rect.y || a.y === rect.y + rect.height) && Math.max(Math.min(a.x, b.x), rect.x) < Math.min(Math.max(a.x, b.x), rect.x + rect.width);
return verticalBoundary || horizontalBoundary;
}
test("orthogonal routing emits deterministic waypoints around blocking nodes", () => {
const obstacle: DiagramGeometry = { x: 220, y: 60, width: 120, height: 100 };
const page: DiagramPage = {
id: "route",
title: "Route",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "source", label: "Source", geometry: { x: 40, y: 80, width: 120, height: 60 } },
{ id: "blocker", label: "Blocker", geometry: obstacle },
{ id: "target", label: "Target", geometry: { x: 420, y: 80, width: 120, height: 60 } },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const first = routePageEdges(page);
const second = routePageEdges(page);
assert.deepEqual(first, second);
const waypoints = first.edges[0].waypoints!;
assert.ok(waypoints.length >= 2);
const path = [{ x: 160, y: 110 }, ...waypoints, { x: 420, y: 110 }];
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], obstacle), false);
}
assert.match(first.edges[0].style ?? "", /edgeStyle=none/);
});
test("self-loop routing produces a visible external loop", () => {
const page: DiagramPage = {
id: "loop",
title: "Loop",
layout: { type: "manual", gridSize: 10 },
nodes: [{ id: "node", label: "Node", geometry: { x: 100, y: 100, width: 120, height: 60 } }],
edges: [{ id: "loop-edge", source: "node", target: "node" }],
};
const edge = routePageEdges(page).edges[0];
assert.ok((edge.waypoints?.length ?? 0) >= 3);
assert.ok(new Set(edge.waypoints!.map((point) => `${point.x},${point.y}`)).size >= 3);
assert.match(edge.style ?? "", /edgeStyle=none/);
});
test("routing leaves endpoint boundaries immediately instead of following their edges", () => {
const source: DiagramGeometry = { x: 40, y: 40, width: 120, height: 80 };
const target: DiagramGeometry = { x: 300, y: 180, width: 120, height: 80 };
const page: DiagramPage = {
id: "diagonal",
title: "Diagonal",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "source", label: "Source", geometry: source },
{ id: "target", label: "Target", geometry: target },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const routed = routePageEdges(page);
const points = [{ x: 160, y: 80 }, ...routed.edges[0].waypoints!, { x: 300, y: 220 }];
for (let index = 0; index < points.length - 1; index += 1) {
assert.equal(segmentAlongRectEdge(points[index], points[index + 1], source), false);
assert.equal(segmentAlongRectEdge(points[index], points[index + 1], target), false);
}
});
test("routing resolves nested node geometry to page coordinates", () => {
const page: DiagramPage = {
id: "nested",
title: "Nested",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: { x: 100, y: 100, width: 240, height: 200 } },
{ id: "source", label: "Source", parentId: "zone", geometry: { x: 40, y: 60, width: 100, height: 60 } },
{ id: "blocker", label: "Blocker", geometry: { x: 360, y: 140, width: 100, height: 100 } },
{ id: "target", label: "Target", geometry: { x: 500, y: 160, width: 100, height: 60 } },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const routed = routePageEdges(page);
const waypoints = routed.edges[0].waypoints!;
const absoluteSourceEdge = { x: 240, y: 190 };
const absoluteTargetEdge = { x: 500, y: 190 };
const blocker = page.nodes[2].geometry!;
const path = [absoluteSourceEdge, ...waypoints, absoluteTargetEdge];
assert.ok(waypoints.length >= 2);
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], blocker), false);
}
});
@@ -0,0 +1,179 @@
import type { DiagramEdge, DiagramGeometry, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
interface Obstacle extends DiagramGeometry {
id: string;
}
function center(geometry: DiagramGeometry): DiagramPoint {
return { x: geometry.x + geometry.width / 2, y: geometry.y + geometry.height / 2 };
}
function endpointPair(source: DiagramGeometry, target: DiagramGeometry): { source: DiagramPoint; target: DiagramPoint } {
const a = center(source);
const b = center(target);
const dx = b.x - a.x;
const dy = b.y - a.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { source: { x: source.x + source.width, y: a.y }, target: { x: target.x, y: b.y } }
: { source: { x: source.x, y: a.y }, target: { x: target.x + target.width, y: b.y } };
}
return dy >= 0
? { source: { x: a.x, y: source.y + source.height }, target: { x: b.x, y: target.y } }
: { source: { x: a.x, y: source.y }, target: { x: b.x, y: target.y + target.height } };
}
function outwardStub(point: DiagramPoint, geometry: DiagramGeometry, distance: number): DiagramPoint {
const middle = center(geometry);
return {
x: point.x + Math.sign(point.x - middle.x) * distance,
y: point.y + Math.sign(point.y - middle.y) * distance,
};
}
function compactPath(path: DiagramPoint[]): DiagramPoint[] {
const deduped = path.filter((point, index) => index === 0 || point.x !== path[index - 1].x || point.y !== path[index - 1].y);
return deduped.filter((point, index) => {
if (index === 0 || index === deduped.length - 1) return true;
const previous = deduped[index - 1];
const next = deduped[index + 1];
return !((previous.x === point.x && point.x === next.x) || (previous.y === point.y && point.y === next.y));
});
}
function segmentIntersectsRect(a: DiagramPoint, b: DiagramPoint, rect: Obstacle, padding: number): boolean {
const left = rect.x - padding;
const right = rect.x + rect.width + padding;
const top = rect.y - padding;
const bottom = rect.y + rect.height + padding;
if (a.y === b.y) {
return a.y > top && a.y < bottom && Math.max(Math.min(a.x, b.x), left) < Math.min(Math.max(a.x, b.x), right);
}
if (a.x === b.x) {
return a.x > left && a.x < right && Math.max(Math.min(a.y, b.y), top) < Math.min(Math.max(a.y, b.y), bottom);
}
return true;
}
function pathClear(path: DiagramPoint[], obstacles: Obstacle[], padding: number): boolean {
for (let index = 0; index < path.length - 1; index += 1) {
for (const obstacle of obstacles) {
if (segmentIntersectsRect(path[index], path[index + 1], obstacle, padding)) return false;
}
}
return true;
}
function pathLength(path: DiagramPoint[]): number {
let total = 0;
for (let index = 0; index < path.length - 1; index += 1) {
total += Math.abs(path[index + 1].x - path[index].x) + Math.abs(path[index + 1].y - path[index].y);
}
return total;
}
function routedStyle(style?: string): string {
const base = style ?? "rounded=0;orthogonalLoop=1;html=1;";
if (/edgeStyle=[^;]*;?/.test(base)) return base.replace(/edgeStyle=[^;]*;?/, "edgeStyle=none;");
return `edgeStyle=none;${base}`;
}
function absoluteGeometryById(page: DiagramPage): Map<string, DiagramGeometry> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const result = new Map<string, DiagramGeometry>();
const resolving = new Set<string>();
const resolve = (id: string): DiagramGeometry | undefined => {
const cached = result.get(id);
if (cached) return cached;
const node = nodes.get(id);
if (!node?.geometry || resolving.has(id)) return undefined;
resolving.add(id);
const parent = node.parentId ? resolve(node.parentId) : undefined;
resolving.delete(id);
const absolute = {
...node.geometry,
x: node.geometry.x + (parent?.x ?? 0),
y: node.geometry.y + (parent?.y ?? 0),
};
result.set(id, absolute);
return absolute;
};
for (const id of nodes.keys()) resolve(id);
return result;
}
function ancestorIds(page: DiagramPage, id: string): Set<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>();
let parentId = nodes.get(id)?.parentId;
while (parentId && !ancestors.has(parentId)) {
ancestors.add(parentId);
parentId = nodes.get(parentId)?.parentId;
}
return ancestors;
}
function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string, DiagramGeometry>, padding: number, grid: number): DiagramEdge {
if (edge.waypoints?.length) return { ...edge, waypoints: edge.waypoints.map((point) => ({ ...point })) };
const source = geometries.get(edge.source);
const target = geometries.get(edge.target);
if (!source || !target) return { ...edge };
const snap = (value: number) => Math.round(value / grid) * grid;
if (edge.source === edge.target) {
const right = snap(source.x + source.width + padding * 2);
const top = snap(source.y - padding * 2);
return {
...edge,
style: routedStyle(edge.style),
waypoints: [
{ x: right, y: snap(source.y + source.height / 2) },
{ x: right, y: top },
{ x: snap(source.x + source.width / 2), y: top },
],
};
}
const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]);
const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id))
.map((node) => ({ id: node.id, ...geometries.get(node.id)! }));
const sourceStub = outwardStub(endpoints.source, source, padding);
const targetStub = outwardStub(endpoints.target, target, padding);
const middleX = snap((sourceStub.x + targetStub.x) / 2);
const middleY = snap((sourceStub.y + targetStub.y) / 2);
const directCandidates: DiagramPoint[][] = endpoints.source.x === endpoints.target.x || endpoints.source.y === endpoints.target.y
? [[endpoints.source, endpoints.target]]
: [
[endpoints.source, sourceStub, { x: middleX, y: sourceStub.y }, { x: middleX, y: targetStub.y }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: sourceStub.x, y: middleY }, { x: targetStub.x, y: middleY }, targetStub, endpoints.target],
];
const minY = Math.min(source.y, target.y, ...obstacles.map((item) => item.y));
const maxY = Math.max(source.y + source.height, target.y + target.height, ...obstacles.map((item) => item.y + item.height));
const minX = Math.min(source.x, target.x, ...obstacles.map((item) => item.x));
const maxX = Math.max(source.x + source.width, target.x + target.width, ...obstacles.map((item) => item.x + item.width));
const corridorCandidates: DiagramPoint[][] = [
[endpoints.source, sourceStub, { x: sourceStub.x, y: snap(minY - padding * 2) }, { x: targetStub.x, y: snap(minY - padding * 2) }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: sourceStub.x, y: snap(maxY + padding * 2) }, { x: targetStub.x, y: snap(maxY + padding * 2) }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: snap(minX - padding * 2), y: sourceStub.y }, { x: snap(minX - padding * 2), y: targetStub.y }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: snap(maxX + padding * 2), y: sourceStub.y }, { x: snap(maxX + padding * 2), y: targetStub.y }, targetStub, endpoints.target],
];
const candidates = [...directCandidates, ...corridorCandidates]
.map(compactPath)
.filter((path) => pathClear(path, obstacles, padding))
.sort((a, b) => pathLength(a) - pathLength(b) || JSON.stringify(a).localeCompare(JSON.stringify(b)));
const selected = candidates[0];
if (!selected) return { ...edge };
return { ...edge, style: routedStyle(edge.style), waypoints: selected.slice(1, -1) };
}
export function routePageEdges(page: DiagramPage, padding = 10): DiagramPage {
const grid = page.layout?.gridSize ?? 10;
const geometries = absoluteGeometryById(page);
return {
...page,
nodes: page.nodes.map((node) => ({ ...node, geometry: node.geometry ? { ...node.geometry } : undefined })),
edges: page.edges.map((edge) => routeEdge(edge, page, geometries, padding, grid)),
};
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { decodeDiagramContent, parseAllPages } from "./parser.js";
const SIMPLE_MODEL = `<mxGraphModel pageWidth="800" pageHeight="600"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="node" value="Node" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="80" as="geometry"/></mxCell></root></mxGraphModel>`;
function withDrawioFile(content: string, run: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-parser-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
run(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("parseAllPages accepts an XML declaration and UTF-8 BOM", () => {
withDrawioFile(`\uFEFF<?xml version="1.0" encoding="UTF-8"?>\n${SIMPLE_MODEL}`, (filePath) => {
const pages = parseAllPages(filePath);
assert.equal(pages.length, 1);
assert.equal(pages[0].shapes.get("node")?.label, "Node");
});
});
test("decodeDiagramContent rejects compressed input beyond configured limits", () => {
const encoded = Buffer.from("compressed bytes").toString("base64");
assert.throws(
() => decodeDiagramContent(encoded, { maxCompressedBytes: 2, maxDecompressedBytes: 32 }),
/compressed diagram exceeds 2 bytes/,
);
});
test("parseAllPages rejects malformed mxfile wrappers and trailing junk", () => {
withDrawioFile(`<mxfile><diagram name="Page">${SIMPLE_MODEL}</diagram></mxfile>trailing`, (filePath) => {
assert.throws(() => parseAllPages(filePath), /invalid XML/i);
});
});
+72 -22
View File
@@ -11,7 +11,7 @@
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { inflateRaw } from "pako"; import { Inflate } from "pako";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { import {
GraphDataModel, GraphDataModel,
@@ -104,16 +104,55 @@ export interface ParsedDiagram extends ParsedPage {}
// Decode <mxfile> base64+deflate diagram content // Decode <mxfile> base64+deflate diagram content
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function decodeDiagramContent(content: string): string { export interface DecodeLimits {
try { maxCompressedBytes: number;
const decoded = Buffer.from(content.trim(), "base64"); maxDecompressedBytes: number;
const decompressed = inflateRaw(decoded);
const text = new TextDecoder("utf-8").decode(decompressed);
return decodeURIComponent(text);
} catch {
// Already plain XML
return content;
} }
export function normalizeDrawioDocument(content: string): string {
return content
.replace(/^\uFEFF/, "")
.replace(/^\s*<\?xml[^?]*\?>/i, "")
.trim();
}
const DEFAULT_DECODE_LIMITS: DecodeLimits = {
maxCompressedBytes: 16 * 1024 * 1024,
maxDecompressedBytes: 64 * 1024 * 1024,
};
export function decodeDiagramContent(
content: string,
limits: DecodeLimits = DEFAULT_DECODE_LIMITS,
): string {
const trimmed = content.trim();
if (/^<mxGraphModel/i.test(trimmed)) return trimmed;
const decoded = Buffer.from(trimmed, "base64");
if (decoded.byteLength > limits.maxCompressedBytes) {
throw new Error(`compressed diagram exceeds ${limits.maxCompressedBytes} bytes`);
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
const inflater = new Inflate({ raw: true });
inflater.onData = (chunk: Uint8Array) => {
totalBytes += chunk.byteLength;
if (totalBytes > limits.maxDecompressedBytes) {
throw new Error(`Decompressed diagram exceeds ${limits.maxDecompressedBytes} bytes`);
}
chunks.push(chunk);
};
inflater.push(decoded, true);
if (inflater.err) throw new Error(inflater.msg || "Unable to decompress diagram");
const decompressed = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
decompressed.set(chunk, offset);
offset += chunk.byteLength;
}
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -233,6 +272,7 @@ function cellToEdge(cell: Cell): Edge | null {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage { function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage {
assertWellFormedXml(graphModelXml, "mxGraphModel");
const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml); const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml);
const model = new GraphDataModel(); const model = new GraphDataModel();
@@ -263,18 +303,28 @@ interface DiagramBlock {
content: string; content: string;
} }
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] { function assertWellFormedXml(xml: string, expectedRoot: string): Document {
const blocks: DiagramBlock[] = []; if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
// Match each <diagram ...>...</diagram> element const document = new DOMParser().parseFromString(xml, "text/xml");
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi; const parseError = document.querySelector("parsererror");
let match: RegExpExecArray | null; if (parseError || document.documentElement.tagName !== expectedRoot) {
while ((match = re.exec(mxfileXml)) !== null) { throw new Error(`Invalid XML: expected complete <${expectedRoot}> document`);
const attrs = match[1];
const content = match[2];
const name = getAttrFromTag(attrs, "name") || "";
blocks.push({ name, content: content.trim() });
} }
return blocks; return document;
}
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
const document = assertWellFormedXml(mxfileXml, "mxfile");
const serializer = new XMLSerializer();
return Array.from(document.documentElement.children)
.filter((element) => element.tagName === "diagram")
.map((element) => {
const graphModel = Array.from(element.children).find((child) => child.tagName === "mxGraphModel");
return {
name: element.getAttribute("name") ?? "",
content: graphModel ? serializer.serializeToString(graphModel) : (element.textContent ?? "").trim(),
};
});
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -286,7 +336,7 @@ function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
* Returns one ParsedPage per diagram/tab found. * Returns one ParsedPage per diagram/tab found.
*/ */
export function parseAllPages(filePath: string): ParsedPage[] { export function parseAllPages(filePath: string): ParsedPage[] {
const raw = readFileSync(filePath, "utf-8").trim(); const raw = normalizeDrawioDocument(readFileSync(filePath, "utf-8"));
if (/^<mxfile/i.test(raw)) { if (/^<mxfile/i.test(raw)) {
const blocks = extractDiagramBlocks(raw); const blocks = extractDiagramBlocks(raw);
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { layoutPage } from "./layout-engine.js";
const PAGE: DiagramPage = {
id: "system",
title: "System",
layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 },
nodes: [
{ id: "client", label: "Client", width: 120, height: 60 },
{ id: "worker", label: "Worker", width: 120, height: 60 },
{ id: "api", label: "API", width: 120, height: 60 },
{ id: "db", label: "Database", width: 120, height: 60 },
],
edges: [
{ id: "client-api", source: "client", target: "api" },
{ id: "worker-api", source: "worker", target: "api" },
{ id: "api-db", source: "api", target: "db" },
],
};
test("layered layout is deterministic, dependency-aware, and grid-aligned", () => {
const first = layoutPage(PAGE);
const second = layoutPage(PAGE);
assert.deepEqual(first, second);
const positions = new Map(first.nodes.map((node) => [node.id, node.geometry!]));
assert.ok(positions.get("client")!.x < positions.get("api")!.x);
assert.ok(positions.get("worker")!.x < positions.get("api")!.x);
assert.ok(positions.get("api")!.x < positions.get("db")!.x);
assert.notEqual(positions.get("client")!.y, positions.get("worker")!.y);
for (const geometry of positions.values()) {
assert.equal(geometry.x % 10, 0);
assert.equal(geometry.y % 10, 0);
}
});
test("layered layout avoids explicit-geometry siblings", () => {
const page: DiagramPage = {
id: "explicit",
title: "Explicit",
layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 },
nodes: [
{ id: "a", label: "A", geometry: { x: 40, y: 80, width: 120, height: 60 } },
{ id: "b", label: "B" },
],
edges: [],
};
const laidOut = layoutPage(page);
const [a, b] = laidOut.nodes.map((node) => node.geometry!);
const overlaps = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
assert.equal(overlaps, false);
});
test("manual layout preserves explicit geometry", () => {
const page: DiagramPage = {
id: "manual",
title: "Manual",
layout: { type: "manual" },
nodes: [{ id: "node", label: "Node", geometry: { x: 35, y: 45, width: 130, height: 70 } }],
edges: [],
};
assert.deepEqual(layoutPage(page), page);
});
test("grid and linear layouts avoid explicit siblings and honor grid/gap settings", () => {
for (const type of ["grid", "linear"] as const) {
const page: DiagramPage = {
id: `mixed-${type}`,
title: "Mixed",
layout: { type, direction: "horizontal", gridSize: 20, nodeGap: 60 },
nodes: [
{ id: "generated-one", label: "Generated one", width: 130, height: 60 },
{ id: "fixed", label: "Fixed", geometry: { x: 40, y: 80, width: 130, height: 60 } },
{ id: "generated-two", label: "Generated two", width: 130, height: 60 },
],
edges: [],
};
const laidOut = layoutPage(page);
const geometries = laidOut.nodes.map((node) => node.geometry!);
for (const geometry of geometries) {
assert.equal(geometry.x % 20, 0);
assert.equal(geometry.y % 20, 0);
}
for (let left = 0; left < geometries.length; left += 1) for (let right = left + 1; right < geometries.length; right += 1) {
const a = geometries[left];
const b = geometries[right];
const overlaps = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
assert.equal(overlaps, false, `${type} layout overlapped mixed siblings`);
}
if (type === "linear") {
const generated = laidOut.nodes.filter((node) => node.id.startsWith("generated")).map((node) => node.geometry!).sort((a, b) => a.x - b.x);
assert.ok(generated[1].x - (generated[0].x + generated[0].width) >= 60);
}
}
});
test("automatic layout uses parent-relative coordinates and expands containers", () => {
const page: DiagramPage = {
id: "containers",
title: "Containers",
layout: { type: "grid", gridSize: 10, nodeGap: 40 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", width: 120, height: 80 },
{ id: "one", label: "One", parentId: "zone", width: 120, height: 60 },
{ id: "two", label: "Two", parentId: "zone", width: 120, height: 60 },
],
edges: [],
};
const laidOut = layoutPage(page);
const byId = new Map(laidOut.nodes.map((node) => [node.id, node.geometry!]));
const zone = byId.get("zone")!;
for (const childId of ["one", "two"]) {
const child = byId.get(childId)!;
assert.ok(child.x >= 40 && child.y >= 40);
assert.ok(child.x + child.width + 40 <= zone.width);
assert.ok(child.y + child.height + 40 <= zone.height);
}
});
test("automatic layout positions generated sibling containers without overlap", () => {
for (const type of ["grid", "linear"] as const) {
const page: DiagramPage = {
id: "siblings",
title: "Siblings",
layout: { type, gridSize: 10, nodeGap: 40 },
nodes: [
{ id: "left", label: "Left", kind: "container" },
{ id: "left-child", label: "Left child", parentId: "left", width: 240 },
{ id: "right", label: "Right", kind: "container" },
{ id: "right-child", label: "Right child", parentId: "right", width: 240 },
],
edges: [],
};
const laidOut = layoutPage(page);
const byId = new Map(laidOut.nodes.map((node) => [node.id, node.geometry!]));
const left = byId.get("left")!;
const right = byId.get("right")!;
const overlaps = left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
assert.equal(overlaps, false, `${type} layout overlapped sibling containers`);
}
});
@@ -0,0 +1,229 @@
import type { DiagramGeometry, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
function snap(value: number, grid: number): number {
return Math.round(value / grid) * grid;
}
function nodeSize(node: DiagramNode): { width: number; height: number } {
return {
width: node.geometry?.width ?? node.width ?? 120,
height: node.geometry?.height ?? node.height ?? 60,
};
}
function assignLayers(page: DiagramPage): Map<string, number> {
const ids = page.nodes.map((node) => node.id).sort();
const nodeIds = new Set(ids);
const outgoing = new Map(ids.map((id) => [id, [] as string[]]));
const indegree = new Map(ids.map((id) => [id, 0]));
for (const edge of page.edges) {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target) || edge.source === edge.target) continue;
outgoing.get(edge.source)!.push(edge.target);
indegree.set(edge.target, indegree.get(edge.target)! + 1);
}
for (const targets of outgoing.values()) targets.sort();
const queue = ids.filter((id) => indegree.get(id) === 0);
const layer = new Map(ids.map((id) => [id, 0]));
const processed = new Set<string>();
while (queue.length > 0) {
queue.sort();
const id = queue.shift()!;
processed.add(id);
for (const target of outgoing.get(id)!) {
layer.set(target, Math.max(layer.get(target)!, layer.get(id)! + 1));
indegree.set(target, indegree.get(target)! - 1);
if (indegree.get(target) === 0) queue.push(target);
}
}
// Cycles have no complete topological order. Place unresolved nodes in a
// deterministic sequence after the deepest acyclic layer instead of looping.
let fallbackLayer = Math.max(0, ...layer.values());
for (const id of ids.filter((candidate) => !processed.has(candidate))) {
layer.set(id, fallbackLayer++);
}
return layer;
}
function overlaps(a: DiagramGeometry, b: DiagramGeometry): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
function gridLayout(page: DiagramPage, grid: number, nodeGap: number): DiagramPage {
const count = page.nodes.length;
const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
const maxWidth = Math.max(...page.nodes.map((node) => nodeSize(node).width));
const maxHeight = Math.max(...page.nodes.map((node) => nodeSize(node).height));
const occupied = page.nodes.flatMap((node) => node.geometry ? [{ ...node.geometry }] : []);
let slot = 0;
return {
...page,
nodes: page.nodes.map((node) => {
if (node.geometry) return { ...node, geometry: { ...node.geometry } };
const { width, height } = nodeSize(node);
let geometry: DiagramGeometry;
do {
geometry = {
x: snap(40 + (slot % columns) * (maxWidth + nodeGap), grid),
y: snap(80 + Math.floor(slot / columns) * (maxHeight + nodeGap), grid),
width,
height,
};
slot += 1;
} while (occupied.some((existing) => overlaps(geometry, existing)));
occupied.push(geometry);
return { ...node, geometry };
}),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
function layeredLayout(page: DiagramPage, grid: number, nodeGap: number, layerGap: number, horizontal: boolean): DiagramPage {
const layers = assignLayers(page);
const groups = new Map<number, DiagramNode[]>();
for (const node of page.nodes) {
const level = layers.get(node.id) ?? 0;
const group = groups.get(level) ?? [];
group.push(node);
groups.set(level, group);
}
for (const group of groups.values()) group.sort((a, b) => a.id.localeCompare(b.id));
const levels = [...groups.keys()].sort((a, b) => a - b);
const primaryOffsets = new Map<number, number>();
let primary = horizontal ? 40 : 80;
for (const level of levels) {
primaryOffsets.set(level, primary);
const group = groups.get(level)!;
const maxPrimarySize = Math.max(...group.map((node) => horizontal ? nodeSize(node).width : nodeSize(node).height));
primary += maxPrimarySize + layerGap;
}
const geometry = new Map<string, DiagramGeometry>();
for (const node of page.nodes) if (node.geometry) geometry.set(node.id, { ...node.geometry });
for (const level of levels) {
let secondary = horizontal ? 80 : 40;
for (const node of groups.get(level)!) {
if (node.geometry) {
geometry.set(node.id, { ...node.geometry });
continue;
}
const { width, height } = nodeSize(node);
let candidate: DiagramGeometry;
do {
candidate = {
x: snap(horizontal ? primaryOffsets.get(level)! : secondary, grid),
y: snap(horizontal ? secondary : primaryOffsets.get(level)!, grid),
width,
height,
};
if (![...geometry.values()].some((existing) => candidate.x < existing.x + existing.width && candidate.x + candidate.width > existing.x && candidate.y < existing.y + existing.height && candidate.y + candidate.height > existing.y)) break;
secondary += (horizontal ? height : width) + nodeGap;
} while (true);
geometry.set(node.id, candidate);
secondary += (horizontal ? height : width) + nodeGap;
}
}
return {
...page,
nodes: page.nodes.map((node) => ({ ...node, geometry: geometry.get(node.id)! })),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
function layoutFlatPage(page: DiagramPage): DiagramPage {
const type = page.layout?.type ?? "linear";
if (type === "manual") return page;
const grid = page.layout?.gridSize ?? 10;
const nodeGap = page.layout?.nodeGap ?? 40;
const layerGap = page.layout?.layerGap ?? 80;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
if (type === "grid") return gridLayout(page, grid, nodeGap);
if (type === "layered" || type === "tree") return layeredLayout(page, grid, nodeGap, layerGap, horizontal);
let cursor = snap(horizontal ? 40 : 80, grid);
const occupied = page.nodes.flatMap((node) => node.geometry ? [{ ...node.geometry }] : []);
return {
...page,
nodes: page.nodes.map((node) => {
if (node.geometry) return { ...node, geometry: { ...node.geometry } };
const { width, height } = nodeSize(node);
let geometry: DiagramGeometry;
do {
geometry = {
x: snap(horizontal ? cursor : 40, grid),
y: snap(horizontal ? 80 : cursor, grid),
width,
height,
};
const collision = occupied.find((existing) => overlaps(geometry, existing));
if (!collision) break;
cursor = snap((horizontal ? collision.x + collision.width : collision.y + collision.height) + nodeGap, grid);
} while (true);
occupied.push(geometry);
cursor = snap((horizontal ? geometry.x + width : geometry.y + height) + nodeGap, grid);
return { ...node, geometry };
}),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
export function layoutPage(page: DiagramPage): DiagramPage {
if ((page.layout?.type ?? "linear") === "manual") return page;
const nodes = new Map<string, DiagramNode>(page.nodes.map((node) => [node.id, {
...node,
geometry: node.geometry ? { ...node.geometry } : undefined,
}]));
const children = new Map<string, string[]>();
for (const node of nodes.values()) {
const key = node.parentId ?? "";
const group = children.get(key) ?? [];
group.push(node.id);
children.set(key, group);
}
const layOutGroup = (parentId: string): void => {
const ids = children.get(parentId) ?? [];
for (const id of ids) layOutGroup(id);
if (ids.length === 0) return;
const idSet = new Set(ids);
const groupPage: DiagramPage = {
...page,
nodes: ids.map((id) => nodes.get(id)!),
edges: page.edges.filter((edge) => idSet.has(edge.source) && idSet.has(edge.target)),
};
const laidOut = layoutFlatPage(groupPage);
for (const node of laidOut.nodes) nodes.set(node.id, node);
if (parentId) {
const parent = nodes.get(parentId);
if (parent) {
const right = Math.max(...laidOut.nodes.map((node) => node.geometry!.x + node.geometry!.width));
const bottom = Math.max(...laidOut.nodes.map((node) => node.geometry!.y + node.geometry!.height));
const size = nodeSize(parent);
const width = Math.max(size.width, right + 40);
const height = Math.max(size.height, bottom + 40);
nodes.set(parentId, parent.geometry ? {
...parent,
geometry: { ...parent.geometry, width, height },
} : {
...parent,
width,
height,
geometry: undefined,
});
}
}
};
layOutGroup("");
return {
...page,
nodes: page.nodes.map((node) => nodes.get(node.id)!),
edges: page.edges.map((edge) => ({ ...edge })),
};
}