diff --git a/README.md b/README.md index fcaaba0..5afd377 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -37,12 +37,31 @@ cd scripts task run -- --file="diagram.drawio" --action= [--page ] # or directly: node dist/cli/commands.js --file diagram.drawio --action [--page ] + +# 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. ## 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 #### `summary` @@ -84,6 +103,14 @@ Connector types: `directed` (both endpoints connected), `partial` (one missing), ### 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` Detects two categories of layout defects on the first page: diff --git a/SKILL.md b/SKILL.md index 20874cc..91950cb 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,10 +13,11 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an # 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 -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 +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. **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): - [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, # page-connectors-validation, page-labels-validation, page-shape-bbox-validation, # 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) 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/ task build ``` @@ -56,10 +60,12 @@ Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, S ## How to create a diagram -1. **Generate draw.io XML** in mxGraphModel format for the requested diagram -2. **Write the XML** to a `.drawio` file in the current working directory using the Write tool -3. **If the user requested an export format** (png, svg, pdf), locate the draw.io CLI (see below), export with `--embed-diagram`, then delete the source `.drawio` file. If the CLI is not found, keep the `.drawio` file and tell the user they can install the draw.io desktop app to enable export, or open the `.drawio` file directly -4. **Open the result** — the exported file if exported, or the `.drawio` file otherwise. If the open command fails, print the file path so the user can open it manually +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. **Generate draw.io XML** in mxGraphModel format for the requested diagram +3. **Write the XML** to a `.drawio` file in the current working directory using the Write tool +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 @@ -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/...`: ```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: ```bash # 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) -`/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 @@ -146,7 +152,7 @@ drawio -x -f -e -b 10 -o **WSL2 example:** ```bash -`/mnt/c/Program Files/draw.io/draw.io.exe` -x -f png -e -b 10 -o diagram.drawio.png diagram.drawio +"/mnt/c/Program Files/draw.io/draw.io.exe" -x -f png -e -b 10 -o diagram.drawio.png diagram.drawio ``` Key flags: diff --git a/examples/platform-v2.yaml b/examples/platform-v2.yaml new file mode 100644 index 0000000..f61e114 --- /dev/null +++ b/examples/platform-v2.yaml @@ -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 diff --git a/examples/request-flow.yaml b/examples/request-flow.yaml new file mode 100644 index 0000000..6490219 --- /dev/null +++ b/examples/request-flow.yaml @@ -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 diff --git a/references/capabilities.md b/references/capabilities.md index 38c0d84..430fc7d 100644 --- a/references/capabilities.md +++ b/references/capabilities.md @@ -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. -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. --- -## 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. 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 cd /scripts -node dist/cli/commands.js --file --action [--page ] -# short flags: -f, -a, -p, -h +node dist/cli/commands.js --file --action [--page ] [--output ] +# short flags: -f, -a, -p, -o, -h ``` `--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 +#### Authoring + +| Action | Scope | Description | +|---|---|---| +| `build` | YAML Diagram IR | Validate and convert a YAML specification to native `.drawio`; requires `--output` | + #### Inventory | Action | Scope | Description | @@ -46,6 +64,7 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. | 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 | +| `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-labels-validation` | Page 0 | Empty labels, duplicate labels, labels > 80 chars | | `page-shape-bbox-validation` | Page 0 | Non-containment bounding box overlaps between shapes | diff --git a/schemas/diagram-ir-v2.schema.json b/schemas/diagram-ir-v2.schema.json new file mode 100644 index 0000000..67595c2 --- /dev/null +++ b/schemas/diagram-ir-v2.schema.json @@ -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 + } + } +} diff --git a/scripts/.scripts/cli/Taskfile.yml b/scripts/.scripts/cli/Taskfile.yml index 57bef5d..807af38 100644 --- a/scripts/.scripts/cli/Taskfile.yml +++ b/scripts/.scripts/cli/Taskfile.yml @@ -16,7 +16,7 @@ tasks: Usage: task cli:run -- --file="/path/to/diagram.drawio" --action= [--page ] Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation, 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: - | ./.scripts/cli/api/run.sh {{ .CLI_ARGS }} diff --git a/scripts/Taskfile.yml b/scripts/Taskfile.yml index 6fbf5a2..4e898bf 100644 --- a/scripts/Taskfile.yml +++ b/scripts/Taskfile.yml @@ -28,6 +28,16 @@ tasks: CLI_ARGS: "{{ .CLI_ARGS }}" 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: desc: | Validate a .drawio file (XML well-formedness + required structure). diff --git a/scripts/package.json b/scripts/package.json index 159e984..627aa00 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -9,7 +9,8 @@ }, "scripts": { "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": { "@maxgraph/core": "^0.23.0", diff --git a/scripts/src/actions/build/action.test.ts b/scripts/src/actions/build/action.test.ts new file mode 100644 index 0000000..4263645 --- /dev/null +++ b/scripts/src/actions/build/action.test.ts @@ -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="\{"source":"test"\}"/); + assert.match(xml, /data-properties="\{"audience":"engineering"\}"/); + assert.match(xml, /data-kind="actor"/); + assert.match(xml, /data-properties="\{"tier":"edge"\}"/); + assert.match(xml, /data-kind="request"/); + assert.match(xml, /data-properties="\{"protocol":"HTTPS"\}"/); + 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 }); + } +}); diff --git a/scripts/src/actions/build/action.ts b/scripts/src/actions/build/action.ts new file mode 100644 index 0000000..a6e3cc9 --- /dev/null +++ b/scripts/src/actions/build/action.ts @@ -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 { + if (!outputPath) throw new Error("build requires --output "); + 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, + }, + }; +} diff --git a/scripts/src/actions/page-connectors-validation/action.test.ts b/scripts/src/actions/page-connectors-validation/action.test.ts new file mode 100644 index 0000000..ab1c4cb --- /dev/null +++ b/scripts/src/actions/page-connectors-validation/action.test.ts @@ -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 = ` + + + + + +`; + +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 = ` + + + + + + `; + 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); + }); +}); diff --git a/scripts/src/actions/page-connectors-validation/action.ts b/scripts/src/actions/page-connectors-validation/action.ts index e356443..fc76704 100644 --- a/scripts/src/actions/page-connectors-validation/action.ts +++ b/scripts/src/actions/page-connectors-validation/action.ts @@ -205,16 +205,36 @@ export function run(filePath: string): Record { edgeSegList.push({ edge, segs, pts }); } + function ancestorIds(shapeId: string | null): Set { + const ancestors = new Set(); + const visited = new Set(); + 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 // ------------------------------------------------------------------------- for (const { edge, segs } of edgeSegList) { + const endpointAncestors = new Set([ + ...ancestorIds(edge.sourceId), + ...ancestorIds(edge.targetId), + ]); + for (let i = 0; i < segs.length; i++) { const seg = segs[i]; const isFirst = i === 0; const isLast = i === segs.length - 1; for (const [shapeId, shape] of shapes) { + if (endpointAncestors.has(shapeId)) continue; if (isFirst && shapeId === edge.sourceId) continue; if (isLast && shapeId === edge.targetId) continue; diff --git a/scripts/src/actions/page-orphans/action.test.ts b/scripts/src/actions/page-orphans/action.test.ts new file mode 100644 index 0000000..a896ae7 --- /dev/null +++ b/scripts/src/actions/page-orphans/action.test.ts @@ -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 = ` + + + + + +`; + +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); + }); +}); diff --git a/scripts/src/actions/page-orphans/action.ts b/scripts/src/actions/page-orphans/action.ts index da25c1f..5963eda 100644 --- a/scripts/src/actions/page-orphans/action.ts +++ b/scripts/src/actions/page-orphans/action.ts @@ -11,11 +11,24 @@ import { parseDiagram } from "../../services/drawio-parser/parser.js"; export function run(filePath: string): Record { const { shapes, edges } = parseDiagram(filePath); - // Build set of shape IDs that have at least one edge + // 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(); + function markConnectedWithAncestors(shapeId: string): void { + const visited = new Set(); + 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) { - if (e.sourceId && shapes.has(e.sourceId)) connectedShapeIds.add(e.sourceId); - if (e.targetId && shapes.has(e.targetId)) connectedShapeIds.add(e.targetId); + if (e.sourceId && shapes.has(e.sourceId)) markConnectedWithAncestors(e.sourceId); + if (e.targetId && shapes.has(e.targetId)) markConnectedWithAncestors(e.targetId); } const issues: Record[] = []; diff --git a/scripts/src/actions/quality/action.test.ts b/scripts/src/actions/quality/action.test.ts new file mode 100644 index 0000000..8804ed2 --- /dev/null +++ b/scripts/src/actions/quality/action.test.ts @@ -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, ``, "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, ``, "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, ``, "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 }); + } +}); diff --git a/scripts/src/actions/quality/action.ts b/scripts/src/actions/quality/action.ts new file mode 100644 index 0000000..5efcfb6 --- /dev/null +++ b/scripts/src/actions/quality/action.ts @@ -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; +} + +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(//gi, "\n").replace(/<[^>]+>/g, "").replace(/ /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 { + const pages = parseAllPages(filePath); + const issues: QualityIssue[] = []; + + for (const page of pages) { + const fills = new Set(); + 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, + }; +} diff --git a/scripts/src/actions/validate/action.test.ts b/scripts/src/actions/validate/action.test.ts new file mode 100644 index 0000000..3399f85 --- /dev/null +++ b/scripts/src/actions/validate/action.test.ts @@ -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 }); + } +}); diff --git a/scripts/src/actions/validate/action.ts b/scripts/src/actions/validate/action.ts index d6027e5..34c8874 100644 --- a/scripts/src/actions/validate/action.ts +++ b/scripts/src/actions/validate/action.ts @@ -26,30 +26,9 @@ * - Base64/deflate encoding errors in the body */ -import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; -import { inflateRaw } from "pako"; import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function getAttr(tagStr: string, attr: string): string { - const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i"); - const m = re.exec(tagStr); - return m ? (m[1] ?? m[2] ?? "") : ""; -} - -function decodeDiagramContent(content: string): string { - try { - const decoded = Buffer.from(content.trim(), "base64"); - const decompressed = inflateRaw(decoded); - return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed)); - } catch { - return content; // already plain XML - } -} +import { parseAllPages } from "../../services/drawio-parser/parser.js"; interface PageResult { pageIndex: number; @@ -82,29 +61,7 @@ function checkXmlWellFormed(xml: string): string | null { // --------------------------------------------------------------------------- export function run(filePath: string): Record { - const raw = readFileSync(filePath, "utf-8").trim(); - - // Extract pages (support both mxfile wrapper and bare mxGraphModel) - const pages: Array<{ name: string; xml: string }> = []; - - if (/^]*)>([\s\S]*?)<\/diagram>/gi; - let match: RegExpExecArray | null; - let idx = 0; - while ((match = re.exec(raw)) !== null) { - const name = getAttr(match[1], "name") || `Page ${idx + 1}`; - const xml = decodeDiagramContent(match[2].trim()); - pages.push({ name, xml }); - idx++; - } - if (pages.length === 0) { - throw new Error("No elements found in mxfile"); - } - } else if (/^ ({ name: page.pageName, xml: page.graphModelXml })); const results: PageResult[] = []; diff --git a/scripts/src/authoring/ir-to-drawio.ts b/scripts/src/authoring/ir-to-drawio.ts new file mode 100644 index 0000000..81378fd --- /dev/null +++ b/scripts/src/authoring/ir-to-drawio.ts @@ -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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function dataAttributes(values: Record): 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 ` `; +} + +function serializeEdge(edge: DiagramEdge): string { + const points = edge.waypoints?.length + ? `${edge.waypoints.map((point) => ``).join("")}` + : ""; + const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions }); + return ` ${points}`; +} + +function contentExtent(page: DiagramPage): { width: number; height: number } { + const byId = new Map(page.nodes.map((node) => [node.id, node])); + const absolute = new Map(); + 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 ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; +} + +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 `\n\n${normalized.pages.map(serializePage).join("\n")}\n\n`; +} diff --git a/scripts/src/cli/commands.test.ts b/scripts/src/cli/commands.test.ts new file mode 100644 index 0000000..27fa09c --- /dev/null +++ b/scripts/src/cli/commands.test.ts @@ -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, ``, "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 }); + } +}); diff --git a/scripts/src/cli/commands.ts b/scripts/src/cli/commands.ts index c80c1e4..4fa162a 100644 --- a/scripts/src/cli/commands.ts +++ b/scripts/src/cli/commands.ts @@ -28,10 +28,11 @@ import * as yaml from "js-yaml"; // --------------------------------------------------------------------------- type ActionModule = { - run: (filePath: string, pageIndex?: number) => Record; + run: (filePath: string, pageIndex?: number, outputPath?: string) => Record; }; const ACTIONS: Record Promise> = { + "build": () => import("../actions/build/action.js"), "summary": () => import("../actions/summary/action.js"), "page-summary": () => import("../actions/page-summary/action.js"), "page-hierarchy": () => import("../actions/page-hierarchy/action.js"), @@ -44,6 +45,7 @@ const ACTIONS: Record Promise> = { "page-hierarchy-full": () => import("../actions/page-hierarchy-full/action.js"), "page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"), "validate": () => import("../actions/validate/action.js"), + "quality": () => import("../actions/quality/action.js"), }; // --------------------------------------------------------------------------- @@ -51,7 +53,7 @@ const ACTIONS: Record Promise> = { // --------------------------------------------------------------------------- async function main(): Promise { - let values: { file?: string; action?: string; page?: string; help?: boolean }; + let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean }; try { ({ values } = parseArgs({ args: process.argv.slice(2), @@ -59,6 +61,7 @@ async function main(): Promise { file: { type: "string", short: "f" }, action: { type: "string", short: "a" }, page: { type: "string", short: "p" }, + output: { type: "string", short: "o" }, help: { type: "boolean", short: "h" }, }, strict: true, @@ -69,7 +72,7 @@ async function main(): Promise { } if (values.help) { - console.log(`drawio-tools --file --action [--page ] + console.log(`drawio-tools --file --action [--page ] [--output ] Actions: ${Object.keys(ACTIONS).join(", ")}`); process.exit(0); @@ -90,8 +93,12 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); // Parse optional --page argument (0-based index, default 0) let pageIndex = 0; if (values.page !== undefined) { - pageIndex = parseInt(values.page, 10); - if (isNaN(pageIndex) || pageIndex < 0) { + if (!/^\d+$/.test(values.page)) { + 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}`); process.exit(1); } @@ -106,10 +113,14 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); try { const mod = await ACTIONS[actionName](); - const result = mod.run(filePath, pageIndex); + const result = mod.run(filePath, pageIndex, values.output); process.stdout.write( yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 }) ); + const summary = result.summary; + if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record).valid === false) { + process.exitCode = 1; + } } catch (err) { const errorOutput = { error: true, @@ -126,7 +137,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`); function printError(msg: string): void { process.stderr.write(`Error: ${msg}\n`); process.stderr.write( - `Usage: drawio-tools --file --action <${Object.keys(ACTIONS).join("|")}> [--page ]\n` + `Usage: drawio-tools --file --action <${Object.keys(ACTIONS).join("|")}> [--page ] [--output ]\n` ); } diff --git a/scripts/src/model/diagram-ir-schema.test.ts b/scripts/src/model/diagram-ir-schema.test.ts new file mode 100644 index 0000000..737b092 --- /dev/null +++ b/scripts/src/model/diagram-ir-schema.test.ts @@ -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; + $defs: Record; + }; + 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; 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); +}); diff --git a/scripts/src/model/diagram-ir-v2.test.ts b/scripts/src/model/diagram-ir-v2.test.ts new file mode 100644 index 0000000..3b31d44 --- /dev/null +++ b/scripts/src/model/diagram-ir-v2.test.ts @@ -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); +}); diff --git a/scripts/src/model/diagram-ir.ts b/scripts/src/model/diagram-ir.ts new file mode 100644 index 0000000..62213aa --- /dev/null +++ b/scripts/src/model/diagram-ir.ts @@ -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; + provenance?: Record; + extensions?: Record; +} + +export interface DiagramEdge { + id: string; + source: string; + target: string; + label?: string; + kind?: string; + style?: string; + waypoints?: DiagramPoint[]; + properties?: Record; + provenance?: Record; + extensions?: Record; +} + +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; + extensions?: Record; +} + +export interface DiagramIRV2 { + version: 2; + title?: string; + pages: DiagramPage[]; + provenance?: Record; + assets?: Record; + theme?: string; + properties?: Record; + extensions?: Record; +} + +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(["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, allowed: Set, 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, 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 { + 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(); + const nodeIds = new Set(); + + 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(); + 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(); + 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(); + 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, + }, + }], + }; +} diff --git a/scripts/src/services/authoring-router/orthogonal-router.test.ts b/scripts/src/services/authoring-router/orthogonal-router.test.ts new file mode 100644 index 0000000..fe6d729 --- /dev/null +++ b/scripts/src/services/authoring-router/orthogonal-router.test.ts @@ -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); + } +}); diff --git a/scripts/src/services/authoring-router/orthogonal-router.ts b/scripts/src/services/authoring-router/orthogonal-router.ts new file mode 100644 index 0000000..3fa55dd --- /dev/null +++ b/scripts/src/services/authoring-router/orthogonal-router.ts @@ -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 { + const nodes = new Map(page.nodes.map((node) => [node.id, node])); + const result = new Map(); + const resolving = new Set(); + 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 { + const nodes = new Map(page.nodes.map((node) => [node.id, node])); + const ancestors = new Set(); + 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, 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)), + }; +} diff --git a/scripts/src/services/drawio-parser/parser.test.ts b/scripts/src/services/drawio-parser/parser.test.ts new file mode 100644 index 0000000..ca0022c --- /dev/null +++ b/scripts/src/services/drawio-parser/parser.test.ts @@ -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 = ``; + +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\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(`${SIMPLE_MODEL}trailing`, (filePath) => { + assert.throws(() => parseAllPages(filePath), /invalid XML/i); + }); +}); diff --git a/scripts/src/services/drawio-parser/parser.ts b/scripts/src/services/drawio-parser/parser.ts index 1601f0b..72a09e9 100644 --- a/scripts/src/services/drawio-parser/parser.ts +++ b/scripts/src/services/drawio-parser/parser.ts @@ -11,7 +11,7 @@ */ import { readFileSync } from "node:fs"; -import { inflateRaw } from "pako"; +import { Inflate } from "pako"; import { JSDOM } from "jsdom"; import { GraphDataModel, @@ -104,16 +104,55 @@ export interface ParsedDiagram extends ParsedPage {} // Decode base64+deflate diagram content // --------------------------------------------------------------------------- -function decodeDiagramContent(content: string): string { - try { - const decoded = Buffer.from(content.trim(), "base64"); - const decompressed = inflateRaw(decoded); - const text = new TextDecoder("utf-8").decode(decompressed); - return decodeURIComponent(text); - } catch { - // Already plain XML - return content; +export interface DecodeLimits { + maxCompressedBytes: number; + maxDecompressedBytes: number; +} + +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 (/^ 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 { + assertWellFormedXml(graphModelXml, "mxGraphModel"); const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml); const model = new GraphDataModel(); @@ -263,18 +303,28 @@ interface DiagramBlock { content: string; } -function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] { - const blocks: DiagramBlock[] = []; - // Match each ... element - const re = /]*)>([\s\S]*?)<\/diagram>/gi; - let match: RegExpExecArray | null; - while ((match = re.exec(mxfileXml)) !== null) { - const attrs = match[1]; - const content = match[2]; - const name = getAttrFromTag(attrs, "name") || ""; - blocks.push({ name, content: content.trim() }); +function assertWellFormedXml(xml: string, expectedRoot: string): Document { + if (/ document`); } - 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. */ export function parseAllPages(filePath: string): ParsedPage[] { - const raw = readFileSync(filePath, "utf-8").trim(); + const raw = normalizeDrawioDocument(readFileSync(filePath, "utf-8")); if (/^ { + 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`); + } +}); diff --git a/scripts/src/services/layout/layout-engine.ts b/scripts/src/services/layout/layout-engine.ts new file mode 100644 index 0000000..f9c1c44 --- /dev/null +++ b/scripts/src/services/layout/layout-engine.ts @@ -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 { + 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(); + 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(); + 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(); + 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(); + 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(page.nodes.map((node) => [node.id, { + ...node, + geometry: node.geometry ? { ...node.geometry } : undefined, + }])); + const children = new Map(); + 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 })), + }; +}