feat: add deterministic Diagram IR v2 authoring
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
|
||||
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
|
||||
if (!outputPath) throw new Error("build requires --output <path.drawio>");
|
||||
const parsed = yaml.load(readFileSync(filePath, "utf8"), { schema: yaml.JSON_SCHEMA });
|
||||
const ir = validateDiagramIR(parsed);
|
||||
const normalized = normalizeDiagramIR(ir);
|
||||
const xml = diagramIRToDrawio(normalized);
|
||||
const resolvedOutput = resolve(outputPath);
|
||||
writeFileSync(resolvedOutput, xml, "utf8");
|
||||
return {
|
||||
action: "build",
|
||||
file: filePath,
|
||||
output: resolvedOutput,
|
||||
summary: {
|
||||
nodes: normalized.pages.reduce((total, page) => total + page.nodes.length, 0),
|
||||
edges: normalized.pages.reduce((total, page) => total + page.edges.length, 0),
|
||||
pages: normalized.pages.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { run } from "./action.js";
|
||||
|
||||
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-connectors-test-"));
|
||||
const filePath = join(dir, "diagram.drawio");
|
||||
try {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
callback(filePath);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const CHILDREN_CONNECTED_INSIDE_CONTAINER = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
|
||||
<mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
|
||||
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
</root></mxGraphModel>`;
|
||||
|
||||
test("connector validation ignores the shared ancestor container", () => {
|
||||
withDrawioFile(CHILDREN_CONNECTED_INSIDE_CONTAINER, (filePath) => {
|
||||
const result = run(filePath);
|
||||
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
|
||||
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("connector validation ignores every endpoint ancestor for cross-container edges", () => {
|
||||
const diagram = `<mxGraphModel pageWidth="900" pageHeight="600"><root>
|
||||
<mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
|
||||
<mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="external" value="External" vertex="1" parent="1"><mxGeometry x="520" y="100" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="edge" style="edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="child" target="external"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
</root></mxGraphModel>`;
|
||||
withDrawioFile(diagram, (filePath) => {
|
||||
const result = run(filePath);
|
||||
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
|
||||
assert.equal(issues.some((issue) => issue.type === "connector_shape_overlap" && issue.shapeId === "container"), false);
|
||||
});
|
||||
});
|
||||
@@ -205,16 +205,36 @@ export function run(filePath: string): Record<string, unknown> {
|
||||
edgeSegList.push({ edge, segs, pts });
|
||||
}
|
||||
|
||||
function ancestorIds(shapeId: string | null): Set<string> {
|
||||
const ancestors = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
let currentId = shapeId ?? undefined;
|
||||
while (currentId && !visited.has(currentId)) {
|
||||
visited.add(currentId);
|
||||
const shape = shapes.get(currentId);
|
||||
if (!shape) break;
|
||||
currentId = shape.parentId;
|
||||
if (currentId && shapes.has(currentId)) ancestors.add(currentId);
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Connector-shape overlaps
|
||||
// -------------------------------------------------------------------------
|
||||
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;
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { run } from "./action.js";
|
||||
|
||||
function withDrawioFile(content: string, callback: (filePath: string) => void): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-orphans-test-"));
|
||||
const filePath = join(dir, "diagram.drawio");
|
||||
try {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
callback(filePath);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
const CONTAINER_WITH_CONNECTED_CHILDREN = `<mxGraphModel pageWidth="800" pageHeight="600"><root>
|
||||
<mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
<mxCell id="container" value="Domain" style="swimlane;startSize=30;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="400" height="240" as="geometry"/></mxCell>
|
||||
<mxCell id="a" value="A" vertex="1" parent="container"><mxGeometry x="40" y="60" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="b" value="B" vertex="1" parent="container"><mxGeometry x="220" y="60" width="100" height="60" as="geometry"/></mxCell>
|
||||
<mxCell id="edge" edge="1" parent="1" source="a" target="b"><mxGeometry relative="1" as="geometry"/></mxCell>
|
||||
</root></mxGraphModel>`;
|
||||
|
||||
test("page-orphans treats a container with connected descendants as connected", () => {
|
||||
withDrawioFile(CONTAINER_WITH_CONNECTED_CHILDREN, (filePath) => {
|
||||
const result = run(filePath);
|
||||
const summary = result.summary as { isolatedShapes: number };
|
||||
const issues = result.issues as Array<{ type: string; shapeId?: string }>;
|
||||
assert.equal(summary.isolatedShapes, 0);
|
||||
assert.equal(issues.some((issue) => issue.type === "isolated_shape" && issue.shapeId === "container"), false);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,24 @@ import { parseDiagram } from "../../services/drawio-parser/parser.js";
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const { shapes, edges } = parseDiagram(filePath);
|
||||
|
||||
// Build set of shape IDs that have at least one edge
|
||||
// Build set of shape IDs that have at least one edge. Connectivity propagates
|
||||
// to ancestor containers because a container represents its descendants.
|
||||
const connectedShapeIds = new Set<string>();
|
||||
function markConnectedWithAncestors(shapeId: string): void {
|
||||
const visited = new Set<string>();
|
||||
let currentId: string | undefined = shapeId;
|
||||
while (currentId && !visited.has(currentId)) {
|
||||
visited.add(currentId);
|
||||
const shape = shapes.get(currentId);
|
||||
if (!shape) break;
|
||||
connectedShapeIds.add(currentId);
|
||||
currentId = shape.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const e of edges) {
|
||||
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<string, unknown>[] = [];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { run } from "./action.js";
|
||||
|
||||
test("quality reports clipping, text overflow risk, and placeholders", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-test-"));
|
||||
const file = join(dir, "quality.drawio");
|
||||
try {
|
||||
writeFileSync(file, `<mxfile><diagram name="Page"><mxGraphModel pageWidth="300" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="long" value="This label is far too long for this tiny shape" style="fontSize=14;fillColor=#ffffff;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="60" height="20" as="geometry"/></mxCell><mxCell id="outside" value="Outside" style="fillColor=#eeeeee;" vertex="1" parent="1"><mxGeometry x="260" y="160" width="80" height="60" as="geometry"/></mxCell><mxCell id="todo" value="TODO" style="fillColor=#dddddd;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="80" height="40" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
|
||||
const result = run(file) as { summary: { errors: number; warnings: number }; issues: Array<{ type: string; severity: string }> };
|
||||
assert.ok(result.summary.errors >= 1);
|
||||
assert.ok(result.summary.warnings >= 2);
|
||||
assert.ok(result.issues.some((issue) => issue.type === "page_clipping"));
|
||||
assert.ok(result.issues.some((issue) => issue.type === "text_overflow_risk"));
|
||||
assert.ok(result.issues.some((issue) => issue.type === "placeholder_text"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quality skips page-bound checks when legacy diagrams omit page dimensions", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-no-page-test-"));
|
||||
const file = join(dir, "legacy.drawio");
|
||||
try {
|
||||
writeFileSync(file, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="node" value="Node" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
|
||||
const result = run(file) as { issues: Array<{ type: string }> };
|
||||
assert.equal(result.issues.some((issue) => issue.type === "page_clipping" || issue.type === "connector_page_clipping"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("quality checks connector waypoints and measures density without double-counting nested shapes", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-quality-routing-test-"));
|
||||
const file = join(dir, "quality.drawio");
|
||||
try {
|
||||
writeFileSync(file, `<mxGraphModel pageWidth="200" pageHeight="200"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="container" value="Container" style="swimlane;" vertex="1" parent="1"><mxGeometry x="20" y="20" width="120" height="120" as="geometry"/></mxCell><mxCell id="child" value="Child" vertex="1" parent="container"><mxGeometry x="0" y="0" width="120" height="120" as="geometry"/></mxCell><mxCell id="target" value="Target" vertex="1" parent="1"><mxGeometry x="150" y="80" width="40" height="40" as="geometry"/></mxCell><mxCell id="edge" edge="1" parent="1" source="child" target="target"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell></root></mxGraphModel>`, "utf8");
|
||||
const result = run(file) as { issues: Array<{ type: string }> };
|
||||
assert.ok(result.issues.some((issue) => issue.type === "connector_page_clipping"));
|
||||
assert.equal(result.issues.some((issue) => issue.type === "page_density"), false);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { parseAllPages, type Shape } from "../../services/drawio-parser/parser.js";
|
||||
|
||||
type Severity = "error" | "warning";
|
||||
|
||||
interface QualityIssue {
|
||||
type: string;
|
||||
severity: Severity;
|
||||
pageIndex: number;
|
||||
pageName: string;
|
||||
shapeId?: string;
|
||||
shapeLabel?: string;
|
||||
edgeId?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function styleValue(style: string, key: string): string | undefined {
|
||||
const match = new RegExp(`(?:^|;)${key}=([^;]*)`, "i").exec(style);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function plainText(value: string): string {
|
||||
return value.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]+>/g, "").replace(/ /gi, " ").trim();
|
||||
}
|
||||
|
||||
function textOverflowRisk(shape: Shape): { estimatedLines: number; charactersPerLine: number } | null {
|
||||
const text = plainText(shape.label);
|
||||
if (!text) return null;
|
||||
const fontSize = Number(styleValue(shape.style, "fontSize") ?? 12);
|
||||
const horizontalPadding = Number(styleValue(shape.style, "spacingLeft") ?? 8) + Number(styleValue(shape.style, "spacingRight") ?? 8);
|
||||
const verticalPadding = Number(styleValue(shape.style, "spacingTop") ?? 4) + Number(styleValue(shape.style, "spacingBottom") ?? 4);
|
||||
const usableWidth = Math.max(1, shape.width - horizontalPadding);
|
||||
const usableHeight = Math.max(1, shape.height - verticalPadding);
|
||||
const charactersPerLine = Math.max(1, Math.floor(usableWidth / (fontSize * 0.58)));
|
||||
const explicitLines = text.split("\n");
|
||||
const estimatedLines = explicitLines.reduce((total, line) => total + Math.max(1, Math.ceil(line.length / charactersPerLine)), 0);
|
||||
const requiredHeight = estimatedLines * fontSize * 1.35;
|
||||
return requiredHeight > usableHeight ? { estimatedLines, charactersPerLine } : null;
|
||||
}
|
||||
|
||||
function unionArea(shapes: Shape[], pageWidth: number, pageHeight: number): number {
|
||||
const rects = shapes.map((shape) => ({
|
||||
left: Math.max(0, shape.x),
|
||||
right: Math.min(pageWidth, shape.x2),
|
||||
top: Math.max(0, shape.y),
|
||||
bottom: Math.min(pageHeight, shape.y2),
|
||||
})).filter((rect) => rect.right > rect.left && rect.bottom > rect.top);
|
||||
const xs = [...new Set(rects.flatMap((rect) => [rect.left, rect.right]))].sort((a, b) => a - b);
|
||||
let area = 0;
|
||||
for (let index = 0; index < xs.length - 1; index += 1) {
|
||||
const left = xs[index];
|
||||
const right = xs[index + 1];
|
||||
const intervals = rects.filter((rect) => rect.left < right && rect.right > left)
|
||||
.map((rect) => [rect.top, rect.bottom] as const)
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
let covered = 0;
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
for (const [top, bottom] of intervals) {
|
||||
if (start === undefined) { start = top; end = bottom; continue; }
|
||||
if (top > end!) { covered += end! - start; start = top; end = bottom; }
|
||||
else end = Math.max(end!, bottom);
|
||||
}
|
||||
if (start !== undefined) covered += end! - start;
|
||||
area += (right - left) * covered;
|
||||
}
|
||||
return area;
|
||||
}
|
||||
|
||||
export function run(filePath: string): Record<string, unknown> {
|
||||
const pages = parseAllPages(filePath);
|
||||
const issues: QualityIssue[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
const fills = new Set<string>();
|
||||
for (const shape of page.shapes.values()) {
|
||||
const fill = styleValue(shape.style, "fillColor");
|
||||
if (fill && fill !== "none") fills.add(fill.toLowerCase());
|
||||
|
||||
if (page.pageWidth > 0 && page.pageHeight > 0 && (shape.x < 0 || shape.y < 0 || shape.x2 > page.pageWidth || shape.y2 > page.pageHeight)) {
|
||||
issues.push({
|
||||
type: "page_clipping",
|
||||
severity: "error",
|
||||
pageIndex: page.pageIndex,
|
||||
pageName: page.pageName,
|
||||
shapeId: shape.id,
|
||||
shapeLabel: shape.label,
|
||||
details: { bounds: { x: shape.x, y: shape.y, width: shape.width, height: shape.height }, page: { width: page.pageWidth, height: page.pageHeight } },
|
||||
});
|
||||
}
|
||||
|
||||
const overflow = textOverflowRisk(shape);
|
||||
if (overflow) {
|
||||
issues.push({
|
||||
type: "text_overflow_risk",
|
||||
severity: "warning",
|
||||
pageIndex: page.pageIndex,
|
||||
pageName: page.pageName,
|
||||
shapeId: shape.id,
|
||||
shapeLabel: shape.label,
|
||||
details: overflow,
|
||||
});
|
||||
}
|
||||
|
||||
if (/\b(?:todo|tbd|placeholder|lorem ipsum)\b/i.test(plainText(shape.label))) {
|
||||
issues.push({ type: "placeholder_text", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label });
|
||||
}
|
||||
|
||||
const image = styleValue(shape.style, "image");
|
||||
if (image && /^(?:https?:)?\/\//i.test(image)) {
|
||||
issues.push({ type: "external_asset", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, shapeId: shape.id, shapeLabel: shape.label, details: { image } });
|
||||
}
|
||||
}
|
||||
|
||||
if (page.pageWidth > 0 && page.pageHeight > 0) for (const edge of page.edges) {
|
||||
const outside = edge.waypoints.filter((point) => point.x < 0 || point.y < 0 || point.x > page.pageWidth || point.y > page.pageHeight);
|
||||
if (outside.length > 0) {
|
||||
issues.push({
|
||||
type: "connector_page_clipping",
|
||||
severity: "error",
|
||||
pageIndex: page.pageIndex,
|
||||
pageName: page.pageName,
|
||||
edgeId: edge.id,
|
||||
details: { outsideWaypoints: outside, page: { width: page.pageWidth, height: page.pageHeight } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fills.size > 3) {
|
||||
issues.push({ type: "palette_spread", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { distinctFillColors: fills.size, recommendedMaximum: 3 } });
|
||||
}
|
||||
const pageArea = page.pageWidth * page.pageHeight;
|
||||
const occupiedArea = unionArea([...page.shapes.values()], page.pageWidth, page.pageHeight);
|
||||
if (pageArea > 0 && occupiedArea / pageArea > 0.7) {
|
||||
issues.push({ type: "page_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { occupiedRatio: Math.round((occupiedArea / pageArea) * 1000) / 1000, maximum: 0.7 } });
|
||||
}
|
||||
if (page.shapes.size > 0 && page.edges.length / page.shapes.size > 3) {
|
||||
issues.push({ type: "edge_density", severity: "warning", pageIndex: page.pageIndex, pageName: page.pageName, details: { edgesPerShape: Math.round((page.edges.length / page.shapes.size) * 100) / 100, maximum: 3 } });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: "quality",
|
||||
file: filePath,
|
||||
summary: {
|
||||
pages: pages.length,
|
||||
shapes: pages.reduce((total, page) => total + page.shapes.size, 0),
|
||||
edges: pages.reduce((total, page) => total + page.edges.length, 0),
|
||||
errors: issues.filter((issue) => issue.severity === "error").length,
|
||||
warnings: issues.filter((issue) => issue.severity === "warning").length,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { run as build } from "../build/action.js";
|
||||
import { run as validate } from "./action.js";
|
||||
|
||||
test("validate accepts Draw.io generated with a standard XML declaration", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "drawio-validate-test-"));
|
||||
const spec = join(dir, "spec.yaml");
|
||||
const diagram = join(dir, "diagram.drawio");
|
||||
try {
|
||||
writeFileSync(spec, "version: 1\nnodes:\n - { id: node, label: Node }\n", "utf8");
|
||||
build(spec, 0, diagram);
|
||||
const result = validate(diagram);
|
||||
assert.deepEqual(result.summary, { pages: 1, valid: true, invalidPages: 0 });
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -26,30 +26,9 @@
|
||||
* - Base64/deflate encoding errors in the <diagram> body
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { inflateRaw } from "pako";
|
||||
import { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getAttr(tagStr: string, attr: string): string {
|
||||
const re = new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`, "i");
|
||||
const m = re.exec(tagStr);
|
||||
return m ? (m[1] ?? m[2] ?? "") : "";
|
||||
}
|
||||
|
||||
function decodeDiagramContent(content: string): string {
|
||||
try {
|
||||
const decoded = Buffer.from(content.trim(), "base64");
|
||||
const decompressed = inflateRaw(decoded);
|
||||
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
|
||||
} catch {
|
||||
return content; // already plain XML
|
||||
}
|
||||
}
|
||||
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<string, unknown> {
|
||||
const raw = readFileSync(filePath, "utf-8").trim();
|
||||
|
||||
// Extract pages (support both mxfile wrapper and bare mxGraphModel)
|
||||
const pages: Array<{ name: string; xml: string }> = [];
|
||||
|
||||
if (/^<mxfile/i.test(raw)) {
|
||||
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
let idx = 0;
|
||||
while ((match = re.exec(raw)) !== null) {
|
||||
const name = getAttr(match[1], "name") || `Page ${idx + 1}`;
|
||||
const xml = decodeDiagramContent(match[2].trim());
|
||||
pages.push({ name, xml });
|
||||
idx++;
|
||||
}
|
||||
if (pages.length === 0) {
|
||||
throw new Error("No <diagram> elements found in mxfile");
|
||||
}
|
||||
} else if (/^<mxGraphModel/i.test(raw)) {
|
||||
pages.push({ name: "Page 1", xml: raw });
|
||||
} else {
|
||||
throw new Error(`Unrecognised draw.io file format in ${filePath}`);
|
||||
}
|
||||
const pages = parseAllPages(filePath).map((page) => ({ name: page.pageName, xml: page.graphModelXml }));
|
||||
|
||||
const results: PageResult[] = [];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user