@jabworks/condux 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jabworks/condux",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -39,8 +39,11 @@ product family.
|
|
|
39
39
|
│ Wireframes follow references/wireframe-kit.md — wireframe mode │
|
|
40
40
|
│ by default; render mode when structure is signed off or the │
|
|
41
41
|
│ user asks for fidelity. Diagrams follow references/ │
|
|
42
|
-
│ diagram-kit.md.
|
|
43
|
-
│
|
|
42
|
+
│ diagram-kit.md. Diagrams are checked with references/ │
|
|
43
|
+
│ diagram-check.mjs before Step 3 and delivered only clean; a │
|
|
44
|
+
│ skipped check is said, never silent. All styling from │
|
|
45
|
+
│ references/token-core.css. No external assets, no CDN, no │
|
|
46
|
+
│ font files. │
|
|
44
47
|
│ │
|
|
45
48
|
│ Step 3: DELIVER │
|
|
46
49
|
│ Standalone: open in the browser (xdg-open / open). If neither │
|
|
@@ -145,4 +148,5 @@ mockup into the spec directory first and cite the committed copy.
|
|
|
145
148
|
both kits; a box the reader can't disagree with isn't evidence
|
|
146
149
|
✗ Opening a browser tab while running inside /discovery
|
|
147
150
|
✗ Editing the mockups after design sign-off without saying so
|
|
151
|
+
✗ Delivering a diagram the checker flags, or skipping the check without saying so
|
|
148
152
|
```
|
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
// diagram-check.mjs
|
|
2
|
+
//
|
|
3
|
+
// The eyes of an agent that hand-authors inline SVG diagrams (see
|
|
4
|
+
// diagram-kit.md) and cannot see its own render. It reads one file, finds
|
|
5
|
+
// every inline <svg>…</svg> block, and reports the geometry mistakes a human
|
|
6
|
+
// would catch on sight: an edge that runs through a box it does not connect
|
|
7
|
+
// to, a label sitting on another label or on a node's own title text, a
|
|
8
|
+
// title wider than its own box, an edge nobody labeled, and text that falls
|
|
9
|
+
// outside the canvas.
|
|
10
|
+
//
|
|
11
|
+
// This is a regex-level reader, not a DOM. It understands exactly the
|
|
12
|
+
// idioms diagram-kit.md teaches — `<g transform="translate(x,y)">` offsets,
|
|
13
|
+
// `var(--…)` fills/strokes (never resolved — colors play no part in any
|
|
14
|
+
// check), one shared `<marker>` — and two known approximations:
|
|
15
|
+
//
|
|
16
|
+
// - text width is estimated at 0.6 em per character (no font metrics) —
|
|
17
|
+
// measured exact for the kit's mono labels, generous for regular sans,
|
|
18
|
+
// a little narrow for semibold titles on wide fallback fonts;
|
|
19
|
+
// - Bézier and arc commands (C S Q T A) contribute only their final
|
|
20
|
+
// endpoint as a straight segment — curvature itself is invisible to it.
|
|
21
|
+
//
|
|
22
|
+
// Font size is resolved the way a browser cascades it, restricted to what a
|
|
23
|
+
// regex can see: a `.class { font-size: Npx }` rule matching the text's
|
|
24
|
+
// `class`, then a bare `text` / `svg text` rule, then the element's own
|
|
25
|
+
// `font-size` attribute, then one inherited from an enclosing `<g>` or the
|
|
26
|
+
// `<svg>` tag, and finally the browser default of 16px — reported on stderr
|
|
27
|
+
// when it has to be assumed, because an unsized diagram is read blind. Any
|
|
28
|
+
// other selector shape, and any unit but px, is invisible to it.
|
|
29
|
+
//
|
|
30
|
+
// No dependencies, no network, no shelling out, no filesystem writes.
|
|
31
|
+
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import { pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Small geometry helpers. A "box" is always { x0, y0, x1, y1 } in viewBox
|
|
37
|
+
// units; nodes/boundaries are boxes with a `line` and (once assigned) a
|
|
38
|
+
// `title`; texts are boxes with `text`, `line`, `ax`/`ay` (the anchor
|
|
39
|
+
// point), `free`, `owner`, `ownEdge`.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
// What a browser uses when nothing in the document sizes the text.
|
|
43
|
+
const DEFAULT_FONT_SIZE = 16;
|
|
44
|
+
|
|
45
|
+
const shrinkBox = (box, n) => ({ x0: box.x0 + n, y0: box.y0 + n, x1: box.x1 - n, y1: box.y1 - n });
|
|
46
|
+
|
|
47
|
+
const expandBox = (box, n) => shrinkBox(box, -n);
|
|
48
|
+
|
|
49
|
+
const rectIntersect = (a, b) => a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0;
|
|
50
|
+
|
|
51
|
+
const pointInBox = (px, py, box) => px >= box.x0 && px <= box.x1 && py >= box.y0 && py <= box.y1;
|
|
52
|
+
|
|
53
|
+
// inner fits inside outer, both ways rounded by `tol` viewBox units.
|
|
54
|
+
const fullyInside = (inner, outer, tol) =>
|
|
55
|
+
inner.x0 >= outer.x0 - tol && inner.x1 <= outer.x1 + tol && inner.y0 >= outer.y0 - tol && inner.y1 <= outer.y1 + tol;
|
|
56
|
+
|
|
57
|
+
const containsBox = (outer, inner) => fullyInside(inner, outer, 0);
|
|
58
|
+
|
|
59
|
+
// Liang-Barsky clip of a segment against a box. Returns the clipped segment
|
|
60
|
+
// and its length, or null when the segment misses the box entirely.
|
|
61
|
+
const liangBarsky = (x1, y1, x2, y2, box) => {
|
|
62
|
+
const dx = x2 - x1;
|
|
63
|
+
const dy = y2 - y1;
|
|
64
|
+
const p = [-dx, dx, -dy, dy];
|
|
65
|
+
const q = [x1 - box.x0, box.x1 - x1, y1 - box.y0, box.y1 - y1];
|
|
66
|
+
let t0 = 0;
|
|
67
|
+
let t1 = 1;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < 4; i++) {
|
|
70
|
+
if (p[i] === 0) {
|
|
71
|
+
if (q[i] < 0) return null;
|
|
72
|
+
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const r = q[i] / p[i];
|
|
77
|
+
if (p[i] < 0) {
|
|
78
|
+
if (r > t1) return null;
|
|
79
|
+
if (r > t0) t0 = r;
|
|
80
|
+
} else {
|
|
81
|
+
if (r < t0) return null;
|
|
82
|
+
if (r < t1) t1 = r;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (t0 > t1) return null;
|
|
87
|
+
|
|
88
|
+
const cx1 = x1 + t0 * dx;
|
|
89
|
+
const cy1 = y1 + t0 * dy;
|
|
90
|
+
const cx2 = x1 + t1 * dx;
|
|
91
|
+
const cy2 = y1 + t1 * dy;
|
|
92
|
+
|
|
93
|
+
return { length: Math.hypot(cx2 - cx1, cy2 - cy1) };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const distPointToSegment = (px, py, x1, y1, x2, y2) => {
|
|
97
|
+
const dx = x2 - x1;
|
|
98
|
+
const dy = y2 - y1;
|
|
99
|
+
const lenSq = dx * dx + dy * dy;
|
|
100
|
+
// A zero-length segment is a point — clamp collapses to it on its own,
|
|
101
|
+
// but guard the division explicitly rather than relying on NaN behavior.
|
|
102
|
+
const t = lenSq === 0 ? 0 : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq));
|
|
103
|
+
const nx = x1 + t * dx;
|
|
104
|
+
const ny = y1 + t * dy;
|
|
105
|
+
|
|
106
|
+
return Math.hypot(px - nx, py - ny);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Distance from a box to a segment: zero when the segment enters the box,
|
|
110
|
+
// else the nearest of the box's corners. Label attribution measures this, not
|
|
111
|
+
// the box centre — a long start-anchored label beside a vertical edge has its
|
|
112
|
+
// centre far from the line and its near edge right on it, and real diagrams
|
|
113
|
+
// (the 2026-09-08 reporting one, six of seven labels) are drawn that way.
|
|
114
|
+
const distBoxToSegment = (box, x1, y1, x2, y2) => {
|
|
115
|
+
if (liangBarsky(x1, y1, x2, y2, box)) return 0;
|
|
116
|
+
|
|
117
|
+
const corners = [
|
|
118
|
+
[box.x0, box.y0],
|
|
119
|
+
[box.x1, box.y0],
|
|
120
|
+
[box.x0, box.y1],
|
|
121
|
+
[box.x1, box.y1],
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
return Math.min(...corners.map(([px, py]) => distPointToSegment(px, py, x1, y1, x2, y2)));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const fmt = (n) => Math.round(n * 10) / 10;
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Line-number lookup. Built once per source string and reused across every
|
|
131
|
+
// block and element in it, rather than rescanning from zero each time.
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const makeLineFinder = (source) => {
|
|
135
|
+
const offsets = [0];
|
|
136
|
+
for (let i = 0; i < source.length; i++) if (source[i] === '\n') offsets.push(i + 1);
|
|
137
|
+
|
|
138
|
+
return (idx) => {
|
|
139
|
+
let lo = 0;
|
|
140
|
+
let hi = offsets.length - 1;
|
|
141
|
+
while (lo < hi) {
|
|
142
|
+
const mid = (lo + hi + 1) >> 1;
|
|
143
|
+
if (offsets[mid] <= idx) lo = mid;
|
|
144
|
+
else hi = mid - 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return lo + 1;
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Attribute and transform parsing.
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const getAttr = (tag, name) => {
|
|
156
|
+
const dq = new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`).exec(tag);
|
|
157
|
+
if (dq) return dq[1];
|
|
158
|
+
|
|
159
|
+
const sq = new RegExp(`(?:^|\\s)${name}\\s*=\\s*'([^']*)'`).exec(tag);
|
|
160
|
+
|
|
161
|
+
return sq ? sq[1] : undefined;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// translate(x,y) / translate(x y) / translate(x) — the only transform kind
|
|
165
|
+
// diagram-kit.md uses. Anything else is reported once and treated as identity.
|
|
166
|
+
const parseTranslate = (transform) => {
|
|
167
|
+
const m = /translate\(\s*(-?[\d.]+)(?:[ ,]+(-?[\d.]+))?\s*\)/.exec(transform);
|
|
168
|
+
if (!m) return null;
|
|
169
|
+
|
|
170
|
+
return { dx: parseFloat(m[1]), dy: m[2] !== undefined ? parseFloat(m[2]) : 0 };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// The kit's labels lean on entities (`HTTP · ReportingBatchRequest`);
|
|
174
|
+
// left undecoded, `·` counts as five characters and every label box
|
|
175
|
+
// grows by four em-fifths per dot — wide enough to invent overlaps.
|
|
176
|
+
const NAMED_ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', middot: '·', rarr: '→' };
|
|
177
|
+
|
|
178
|
+
const decodeEntities = (s) =>
|
|
179
|
+
s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, body) => {
|
|
180
|
+
if (body[0] === '#') {
|
|
181
|
+
const hex = body[1] === 'x' || body[1] === 'X';
|
|
182
|
+
const code = hex ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
|
|
183
|
+
|
|
184
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? whole;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const stripTags = (s) =>
|
|
191
|
+
decodeEntities(s.replace(/<[^>]+>/g, ''))
|
|
192
|
+
.replace(/\s+/g, ' ')
|
|
193
|
+
.trim();
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// <path d="…"> grammar: M L H V Z absolute/relative, one straight segment per
|
|
197
|
+
// command; C S Q T A keep only the final endpoint (curvature is invisible to
|
|
198
|
+
// this checker — see the header). A second M starts a new subpath, returned
|
|
199
|
+
// as a separate segment list per diagram-kit's "multiple edges in one path"
|
|
200
|
+
// idiom.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
// The two style-rule shapes the checker reads, from every <style> block in
|
|
204
|
+
// the document (comments already blanked): `text { font-size: Npx }` or
|
|
205
|
+
// `svg text { … }` sets the base, `.name { … }` / `text.name { … }` /
|
|
206
|
+
// `svg .name { … }` maps a class. Later rules win, as in a stylesheet.
|
|
207
|
+
// Anything else — element ids, descendant chains, `em`/`rem`/`var()` — is
|
|
208
|
+
// skipped: a size the checker cannot resolve must not become a guess.
|
|
209
|
+
const BASE_SELECTOR = /^(?:svg\s+)?text$/;
|
|
210
|
+
const CLASS_SELECTOR = /^(?:svg\s+)?(?:text)?\.([\w-]+)$/;
|
|
211
|
+
|
|
212
|
+
const parseStyleFontSizes = (source) => {
|
|
213
|
+
const styles = { base: null, classes: new Map() };
|
|
214
|
+
const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
215
|
+
const ruleRe = /([^{}]+)\{([^}]*)\}/g;
|
|
216
|
+
let sm;
|
|
217
|
+
|
|
218
|
+
while ((sm = styleRe.exec(source))) {
|
|
219
|
+
// A CSS comment is not a rule, and left in place it glues itself onto
|
|
220
|
+
// the selector that follows it.
|
|
221
|
+
const css = sm[1].replace(/\/\*[\s\S]*?\*\//g, ' ');
|
|
222
|
+
let rm;
|
|
223
|
+
while ((rm = ruleRe.exec(css))) {
|
|
224
|
+
const size = /(?:^|[\s;{])font-size\s*:\s*(\d+(?:\.\d+)?)px\b/.exec(rm[2]);
|
|
225
|
+
if (!size) continue;
|
|
226
|
+
|
|
227
|
+
for (const selector of rm[1].split(',')) {
|
|
228
|
+
const s = selector.trim();
|
|
229
|
+
if (BASE_SELECTOR.test(s)) styles.base = parseFloat(size[1]);
|
|
230
|
+
const cm = CLASS_SELECTOR.exec(s);
|
|
231
|
+
if (cm) styles.classes.set(cm[1], parseFloat(size[1]));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return styles;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const CURVE_ARG_COUNTS = { C: 6, S: 4, Q: 4, T: 2, A: 7 };
|
|
240
|
+
|
|
241
|
+
const parsePathSegments = (d, warnCurve) => {
|
|
242
|
+
const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e-?\d+)?/g) || [];
|
|
243
|
+
let i = 0;
|
|
244
|
+
let cx = 0;
|
|
245
|
+
let cy = 0;
|
|
246
|
+
let startX = 0;
|
|
247
|
+
let startY = 0;
|
|
248
|
+
let cmd = null;
|
|
249
|
+
let seenM = false;
|
|
250
|
+
let current = [];
|
|
251
|
+
const subpaths = [];
|
|
252
|
+
|
|
253
|
+
while (i < tokens.length) {
|
|
254
|
+
if (/^[a-zA-Z]$/.test(tokens[i])) {
|
|
255
|
+
cmd = tokens[i];
|
|
256
|
+
i++;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!cmd) break;
|
|
260
|
+
|
|
261
|
+
const letter = cmd.toUpperCase();
|
|
262
|
+
const isRel = cmd !== letter;
|
|
263
|
+
|
|
264
|
+
if (letter === 'M') {
|
|
265
|
+
if (seenM) {
|
|
266
|
+
subpaths.push(current);
|
|
267
|
+
current = [];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
seenM = true;
|
|
271
|
+
const x = parseFloat(tokens[i++]);
|
|
272
|
+
const y = parseFloat(tokens[i++]);
|
|
273
|
+
cx = isRel ? cx + x : x;
|
|
274
|
+
cy = isRel ? cy + y : y;
|
|
275
|
+
startX = cx;
|
|
276
|
+
startY = cy;
|
|
277
|
+
// Bare coordinate pairs after M are an implicit lineto.
|
|
278
|
+
cmd = isRel ? 'l' : 'L';
|
|
279
|
+
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (letter === 'L') {
|
|
284
|
+
const x = parseFloat(tokens[i++]);
|
|
285
|
+
const y = parseFloat(tokens[i++]);
|
|
286
|
+
const nx = isRel ? cx + x : x;
|
|
287
|
+
const ny = isRel ? cy + y : y;
|
|
288
|
+
current.push({ x1: cx, y1: cy, x2: nx, y2: ny });
|
|
289
|
+
cx = nx;
|
|
290
|
+
cy = ny;
|
|
291
|
+
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (letter === 'H') {
|
|
296
|
+
const x = parseFloat(tokens[i++]);
|
|
297
|
+
const nx = isRel ? cx + x : x;
|
|
298
|
+
current.push({ x1: cx, y1: cy, x2: nx, y2: cy });
|
|
299
|
+
cx = nx;
|
|
300
|
+
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (letter === 'V') {
|
|
305
|
+
const y = parseFloat(tokens[i++]);
|
|
306
|
+
const ny = isRel ? cy + y : y;
|
|
307
|
+
current.push({ x1: cx, y1: cy, x2: cx, y2: ny });
|
|
308
|
+
cy = ny;
|
|
309
|
+
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (letter === 'Z') {
|
|
314
|
+
current.push({ x1: cx, y1: cy, x2: startX, y2: startY });
|
|
315
|
+
cx = startX;
|
|
316
|
+
cy = startY;
|
|
317
|
+
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (CURVE_ARG_COUNTS[letter]) {
|
|
322
|
+
warnCurve();
|
|
323
|
+
const n = CURVE_ARG_COUNTS[letter];
|
|
324
|
+
const args = [];
|
|
325
|
+
for (let k = 0; k < n; k++) args.push(tokens[i++]);
|
|
326
|
+
|
|
327
|
+
const x = parseFloat(args[n - 2]);
|
|
328
|
+
const y = parseFloat(args[n - 1]);
|
|
329
|
+
const nx = isRel ? cx + x : x;
|
|
330
|
+
const ny = isRel ? cy + y : y;
|
|
331
|
+
current.push({ x1: cx, y1: cy, x2: nx, y2: ny });
|
|
332
|
+
cx = nx;
|
|
333
|
+
cy = ny;
|
|
334
|
+
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Unknown command letter — skip it rather than spin forever.
|
|
339
|
+
i++;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
subpaths.push(current);
|
|
343
|
+
|
|
344
|
+
return subpaths.filter((s) => s.length > 0);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Per-<svg>-block walk. A single left-to-right tag scan tracks a stack of
|
|
349
|
+
// <g> offsets (and font-size inheritance), skips <defs>/<marker> subtrees
|
|
350
|
+
// entirely, and special-cases <text>…</text> to pull out its raw content
|
|
351
|
+
// (and any positioned <tspan> children) without a general text-node model.
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
const walkBlock = (source, blockOffset, blockSource, findLine, notify, styles) => {
|
|
355
|
+
const rects = [];
|
|
356
|
+
const texts = [];
|
|
357
|
+
const edges = [];
|
|
358
|
+
let defaulted = 0;
|
|
359
|
+
const onDefault = () => {
|
|
360
|
+
defaulted++;
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const svgOpenMatch = /^<svg\b[^>]*>/.exec(blockSource);
|
|
364
|
+
const svgOpenTag = svgOpenMatch ? svgOpenMatch[0] : '<svg>';
|
|
365
|
+
const viewBoxAttr = getAttr(svgOpenTag, 'viewBox');
|
|
366
|
+
let viewBox = null;
|
|
367
|
+
if (viewBoxAttr) {
|
|
368
|
+
const nums = viewBoxAttr.trim().split(/[\s,]+/).map(Number);
|
|
369
|
+
if (nums.length === 4 && nums.every((n) => Number.isFinite(n))) {
|
|
370
|
+
const [minX, minY, w, h] = nums;
|
|
371
|
+
viewBox = { minX, minY, w, h, x0: minX, y0: minY, x1: minX + w, y1: minY + h };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let curOffsetX = 0;
|
|
376
|
+
let curOffsetY = 0;
|
|
377
|
+
// null until some enclosing tag carries a font-size — the text element then
|
|
378
|
+
// decides between its own attribute, the style rules, and the default.
|
|
379
|
+
let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || null;
|
|
380
|
+
let skipDepth = 0;
|
|
381
|
+
const stack = [];
|
|
382
|
+
// The frame a closing tag restores: offsets, inherited font size, and
|
|
383
|
+
// whether the subtree was inside <defs>/<marker>.
|
|
384
|
+
const snapshot = () => ({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: skipDepth > 0 });
|
|
385
|
+
|
|
386
|
+
const tagRe = /<\/?[a-zA-Z][^>]*>/g;
|
|
387
|
+
let m;
|
|
388
|
+
|
|
389
|
+
while ((m = tagRe.exec(blockSource))) {
|
|
390
|
+
const tag = m[0];
|
|
391
|
+
const idx = m.index;
|
|
392
|
+
const lineNo = findLine(blockOffset + idx);
|
|
393
|
+
const closing = tag.startsWith('</');
|
|
394
|
+
const selfClosing = /\/\s*>$/.test(tag);
|
|
395
|
+
const nameMatch = /^<\/?([a-zA-Z][\w:-]*)/.exec(tag);
|
|
396
|
+
const name = nameMatch ? nameMatch[1] : '';
|
|
397
|
+
|
|
398
|
+
if (closing) {
|
|
399
|
+
const frame = stack.pop();
|
|
400
|
+
if (frame) {
|
|
401
|
+
curOffsetX = frame.prevX;
|
|
402
|
+
curOffsetY = frame.prevY;
|
|
403
|
+
curFontSize = frame.prevFontSize;
|
|
404
|
+
if (frame.skip) skipDepth--;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (name === 'text' && !selfClosing) {
|
|
411
|
+
const contentStart = tagRe.lastIndex;
|
|
412
|
+
const closeIdx = blockSource.indexOf('</text>', contentStart);
|
|
413
|
+
const rawContent = closeIdx === -1 ? blockSource.slice(contentStart) : blockSource.slice(contentStart, closeIdx);
|
|
414
|
+
|
|
415
|
+
if (skipDepth === 0) {
|
|
416
|
+
processTextElement({
|
|
417
|
+
tag,
|
|
418
|
+
rawContent,
|
|
419
|
+
contentOffset: blockOffset + contentStart,
|
|
420
|
+
findLine,
|
|
421
|
+
offX: curOffsetX,
|
|
422
|
+
offY: curOffsetY,
|
|
423
|
+
curFontSize,
|
|
424
|
+
styles,
|
|
425
|
+
onDefault,
|
|
426
|
+
texts,
|
|
427
|
+
lineNo,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
tagRe.lastIndex = closeIdx === -1 ? blockSource.length : closeIdx + '</text>'.length;
|
|
432
|
+
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (name === 'defs' || name === 'marker') {
|
|
437
|
+
if (!selfClosing) {
|
|
438
|
+
stack.push({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: true });
|
|
439
|
+
skipDepth++;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (name === 'g') {
|
|
446
|
+
if (!selfClosing) {
|
|
447
|
+
const transform = getAttr(tag, 'transform');
|
|
448
|
+
let dx = 0;
|
|
449
|
+
let dy = 0;
|
|
450
|
+
if (transform) {
|
|
451
|
+
const parsed = parseTranslate(transform);
|
|
452
|
+
if (parsed) {
|
|
453
|
+
dx = parsed.dx;
|
|
454
|
+
dy = parsed.dy;
|
|
455
|
+
} else {
|
|
456
|
+
notify(`diagram-check: unsupported transform "${transform}" at line ${lineNo} — treated as identity`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const fontSizeAttr = getAttr(tag, 'font-size');
|
|
461
|
+
stack.push(snapshot());
|
|
462
|
+
|
|
463
|
+
if (skipDepth === 0) {
|
|
464
|
+
curOffsetX += dx;
|
|
465
|
+
curOffsetY += dy;
|
|
466
|
+
if (fontSizeAttr) curFontSize = parseFloat(fontSizeAttr);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (name === 'svg') {
|
|
474
|
+
if (!selfClosing) stack.push(snapshot());
|
|
475
|
+
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (skipDepth === 0) {
|
|
480
|
+
if (name === 'rect') processRect(tag, lineNo, curOffsetX, curOffsetY, rects);
|
|
481
|
+
else if (name === 'line') processLine(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
|
|
482
|
+
else if (name === 'polyline') processPolyline(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
|
|
483
|
+
else if (name === 'path') processPath(tag, lineNo, curOffsetX, curOffsetY, edges, notify);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Any other container tag (unlikely inside a diagram-kit SVG, but a
|
|
487
|
+
// stray <a> or <clipPath> would otherwise desync the stack) — push a
|
|
488
|
+
// neutral frame so its closing tag pops correctly.
|
|
489
|
+
if (!selfClosing) stack.push(snapshot());
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (defaulted > 0) {
|
|
493
|
+
notify(
|
|
494
|
+
`diagram-check: ${defaulted} text element(s) carry no font-size from an attribute, an enclosing tag, or a text/.class style rule — assumed ${DEFAULT_FONT_SIZE}px; set font-size on the <svg> tag so widths are read as drawn`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { rects, texts, edges, viewBox };
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const processRect = (tag, lineNo, offX, offY, rects) => {
|
|
502
|
+
const x = parseFloat(getAttr(tag, 'x') ?? '0');
|
|
503
|
+
const y = parseFloat(getAttr(tag, 'y') ?? '0');
|
|
504
|
+
const w = parseFloat(getAttr(tag, 'width') ?? '0');
|
|
505
|
+
const h = parseFloat(getAttr(tag, 'height') ?? '0');
|
|
506
|
+
const stroke = getAttr(tag, 'stroke');
|
|
507
|
+
|
|
508
|
+
// No stroke, or an explicit "none", means a decoration (a label halo) —
|
|
509
|
+
// it never obstructs an edge and never receives a label.
|
|
510
|
+
if (!stroke || stroke === 'none') return;
|
|
511
|
+
|
|
512
|
+
rects.push({ x0: x + offX, y0: y + offY, x1: x + offX + w, y1: y + offY + h, line: lineNo });
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// A NaN coordinate compares false against everything, so an edge carrying
|
|
516
|
+
// one would pass every check unseen. Report it and leave it out instead.
|
|
517
|
+
const pushEdge = (edges, edge, notify) => {
|
|
518
|
+
const finite = edge.segments.every((s) => [s.x1, s.y1, s.x2, s.y2].every(Number.isFinite));
|
|
519
|
+
if (!finite) {
|
|
520
|
+
notify(`diagram-check: ${edge.kind} at line ${edge.line} has a non-numeric coordinate — skipped`);
|
|
521
|
+
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
edges.push(edge);
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const processLine = (tag, lineNo, offX, offY, edges, notify) => {
|
|
529
|
+
const x1 = parseFloat(getAttr(tag, 'x1') ?? '0') + offX;
|
|
530
|
+
const y1 = parseFloat(getAttr(tag, 'y1') ?? '0') + offY;
|
|
531
|
+
const x2 = parseFloat(getAttr(tag, 'x2') ?? '0') + offX;
|
|
532
|
+
const y2 = parseFloat(getAttr(tag, 'y2') ?? '0') + offY;
|
|
533
|
+
|
|
534
|
+
pushEdge(edges, { kind: 'line', segments: [{ x1, y1, x2, y2 }], line: lineNo }, notify);
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const processPolyline = (tag, lineNo, offX, offY, edges, notify) => {
|
|
538
|
+
const pointsStr = getAttr(tag, 'points');
|
|
539
|
+
if (!pointsStr) return;
|
|
540
|
+
|
|
541
|
+
const nums = pointsStr.trim().split(/[\s,]+/).map(Number);
|
|
542
|
+
const points = [];
|
|
543
|
+
for (let i = 0; i + 1 < nums.length; i += 2) points.push({ x: nums[i] + offX, y: nums[i + 1] + offY });
|
|
544
|
+
|
|
545
|
+
const segments = [];
|
|
546
|
+
for (let i = 0; i + 1 < points.length; i++) {
|
|
547
|
+
segments.push({ x1: points[i].x, y1: points[i].y, x2: points[i + 1].x, y2: points[i + 1].y });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (segments.length > 0) pushEdge(edges, { kind: 'polyline', segments, line: lineNo }, notify);
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const processPath = (tag, lineNo, offX, offY, edges, notify) => {
|
|
554
|
+
const fill = getAttr(tag, 'fill');
|
|
555
|
+
if (fill && fill !== 'none') return; // fill !== none/absent is a filled shape, not an edge
|
|
556
|
+
|
|
557
|
+
const d = getAttr(tag, 'd');
|
|
558
|
+
if (!d) return;
|
|
559
|
+
|
|
560
|
+
for (const segs of parsePathSegments(d, () => notify.curve())) {
|
|
561
|
+
const offsetSegs = segs.map((s) => ({ x1: s.x1 + offX, y1: s.y1 + offY, x2: s.x2 + offX, y2: s.y2 + offY }));
|
|
562
|
+
pushEdge(edges, { kind: 'path', segments: offsetSegs, line: lineNo }, notify);
|
|
563
|
+
}
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
const pushTextBox = (texts, text, absX, absY, anchor, fontSize, lineNo) => {
|
|
567
|
+
if (!text) return;
|
|
568
|
+
|
|
569
|
+
const width = text.length * fontSize * 0.6;
|
|
570
|
+
const top = absY - 0.75 * fontSize;
|
|
571
|
+
const bottom = absY + 0.25 * fontSize;
|
|
572
|
+
let x0;
|
|
573
|
+
let x1;
|
|
574
|
+
|
|
575
|
+
if (anchor === 'middle') {
|
|
576
|
+
x0 = absX - width / 2;
|
|
577
|
+
x1 = absX + width / 2;
|
|
578
|
+
} else if (anchor === 'end') {
|
|
579
|
+
x0 = absX - width;
|
|
580
|
+
x1 = absX;
|
|
581
|
+
} else {
|
|
582
|
+
x0 = absX;
|
|
583
|
+
x1 = absX + width;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
texts.push({ x0, y0: top, x1, y1: bottom, ax: absX, ay: absY, text, line: lineNo });
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// The cascade a browser applies to a <text>, restricted to the shapes the
|
|
590
|
+
// style scan understands: a matching class rule beats a bare `text` rule,
|
|
591
|
+
// which beats the element's own attribute, which beats an inherited one.
|
|
592
|
+
// `inherited` is null when no enclosing tag carried a size; `onDefault` is
|
|
593
|
+
// called when the browser default has to stand in.
|
|
594
|
+
const resolveFontSize = (tag, inherited, styles, onDefault) => {
|
|
595
|
+
const classAttr = getAttr(tag, 'class');
|
|
596
|
+
if (classAttr) {
|
|
597
|
+
for (const cls of classAttr.trim().split(/\s+/)) {
|
|
598
|
+
if (styles.classes.has(cls)) return styles.classes.get(cls);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (styles.base !== null) return styles.base;
|
|
603
|
+
|
|
604
|
+
const attr = getAttr(tag, 'font-size');
|
|
605
|
+
if (attr) return parseFloat(attr);
|
|
606
|
+
|
|
607
|
+
if (inherited !== null) return inherited;
|
|
608
|
+
|
|
609
|
+
onDefault();
|
|
610
|
+
|
|
611
|
+
return DEFAULT_FONT_SIZE;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, styles, onDefault, texts, lineNo }) => {
|
|
615
|
+
const x = parseFloat(getAttr(tag, 'x') ?? '0');
|
|
616
|
+
const y = parseFloat(getAttr(tag, 'y') ?? '0');
|
|
617
|
+
const anchor = getAttr(tag, 'text-anchor') ?? 'start';
|
|
618
|
+
const fontSize = resolveFontSize(tag, curFontSize, styles, onDefault);
|
|
619
|
+
|
|
620
|
+
const tspanRe = /<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/g;
|
|
621
|
+
const tspans = [];
|
|
622
|
+
let tm;
|
|
623
|
+
while ((tm = tspanRe.exec(rawContent))) {
|
|
624
|
+
const tspanTag = `<tspan ${tm[1]}>`;
|
|
625
|
+
const tx = getAttr(tspanTag, 'x');
|
|
626
|
+
const ty = getAttr(tspanTag, 'y');
|
|
627
|
+
const text = stripTags(tm[2]);
|
|
628
|
+
|
|
629
|
+
if (tx !== undefined && ty !== undefined && text) {
|
|
630
|
+
tspans.push({ tag: tspanTag, x: parseFloat(tx), y: parseFloat(ty), text, index: tm.index });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (tspans.length > 0) {
|
|
635
|
+
for (const t of tspans) {
|
|
636
|
+
const tAnchor = getAttr(t.tag, 'text-anchor') ?? anchor;
|
|
637
|
+
const tFontSizeAttr = getAttr(t.tag, 'font-size');
|
|
638
|
+
const tFontSize = tFontSizeAttr ? parseFloat(tFontSizeAttr) : fontSize;
|
|
639
|
+
const tLine = findLine(contentOffset + t.index);
|
|
640
|
+
pushTextBox(texts, t.text, t.x + offX, t.y + offY, tAnchor, tFontSize, tLine);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const content = stripTags(rawContent);
|
|
647
|
+
pushTextBox(texts, content, x + offX, y + offY, anchor, fontSize, lineNo);
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// Findings for one parsed block.
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
|
|
654
|
+
const describeEdge = (edge) => {
|
|
655
|
+
const first = edge.segments[0];
|
|
656
|
+
const last = edge.segments[edge.segments.length - 1];
|
|
657
|
+
|
|
658
|
+
return `${edge.kind} (${fmt(first.x1)},${fmt(first.y1)})→(${fmt(last.x2)},${fmt(last.y2)})`;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const describeNode = (node) => (node.title ? `"${node.title}"` : `rect (${fmt(node.x0)},${fmt(node.y0)})`);
|
|
662
|
+
|
|
663
|
+
const edgeEndpoints = (edge) => {
|
|
664
|
+
const first = edge.segments[0];
|
|
665
|
+
const last = edge.segments[edge.segments.length - 1];
|
|
666
|
+
|
|
667
|
+
return { start: { x: first.x1, y: first.y1 }, end: { x: last.x2, y: last.y2 } };
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const crossesBox = (edge, box) =>
|
|
671
|
+
edge.segments.some((seg) => {
|
|
672
|
+
const clip = liangBarsky(seg.x1, seg.y1, seg.x2, seg.y2, box);
|
|
673
|
+
|
|
674
|
+
return clip && clip.length > 2;
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
|
|
678
|
+
const findings = [];
|
|
679
|
+
|
|
680
|
+
// Classify rects: a stroked rect that fully contains another stroked rect
|
|
681
|
+
// is a boundary and is never an obstacle; the rest are nodes.
|
|
682
|
+
for (const r of rects) r.isBoundary = rects.some((o) => o !== r && containsBox(r, o));
|
|
683
|
+
const nodes = rects.filter((r) => !r.isBoundary);
|
|
684
|
+
|
|
685
|
+
// Owned vs. free text, and each node's title (first owned text, in
|
|
686
|
+
// document order). A text belongs to the node its box sits in — or, when
|
|
687
|
+
// the box has outgrown the node, the node its anchor point sits in: a
|
|
688
|
+
// title too wide for its box is still that box's title, and reporting it
|
|
689
|
+
// as a free label straddling the node would misname the defect.
|
|
690
|
+
for (const text of texts) {
|
|
691
|
+
const owner = nodes.find((node) => fullyInside(text, node, 1) || pointInBox(text.ax, text.ay, node));
|
|
692
|
+
if (owner) {
|
|
693
|
+
text.free = false;
|
|
694
|
+
text.owner = owner;
|
|
695
|
+
if (!owner.title) owner.title = text.text;
|
|
696
|
+
} else {
|
|
697
|
+
text.free = true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Each free text's own edge: the edge whose nearest segment comes closest
|
|
702
|
+
// to the text box, when that distance is within labeling range.
|
|
703
|
+
for (const text of texts) {
|
|
704
|
+
if (!text.free) continue;
|
|
705
|
+
|
|
706
|
+
let best = null;
|
|
707
|
+
let bestDist = Infinity;
|
|
708
|
+
|
|
709
|
+
for (const edge of edges) {
|
|
710
|
+
for (const seg of edge.segments) {
|
|
711
|
+
const d = distBoxToSegment(text, seg.x1, seg.y1, seg.x2, seg.y2);
|
|
712
|
+
if (d < bestDist) {
|
|
713
|
+
bestDist = d;
|
|
714
|
+
best = edge;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
text.ownEdge = best && bestDist <= 24 ? best : null;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// An edge attaches to a node when either endpoint lands on it (4 units of
|
|
723
|
+
// slack for arrowheads that stop short of the border).
|
|
724
|
+
const isAttached = (edge, node) => {
|
|
725
|
+
const { start, end } = edgeEndpoints(edge);
|
|
726
|
+
const expanded = expandBox(node, 4);
|
|
727
|
+
|
|
728
|
+
return pointInBox(start.x, start.y, expanded) || pointInBox(end.x, end.y, expanded);
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
// 1. edge-through-box
|
|
732
|
+
for (const edge of edges) {
|
|
733
|
+
for (const node of nodes) {
|
|
734
|
+
if (isAttached(edge, node)) continue;
|
|
735
|
+
|
|
736
|
+
if (crossesBox(edge, shrinkBox(node, 1))) {
|
|
737
|
+
findings.push({
|
|
738
|
+
code: 'edge-through-box',
|
|
739
|
+
line: edge.line,
|
|
740
|
+
detail: `${describeEdge(edge)} crosses ${describeNode(node)}`,
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// 2. label-over-label
|
|
747
|
+
for (let i = 0; i < texts.length; i++) {
|
|
748
|
+
for (let j = i + 1; j < texts.length; j++) {
|
|
749
|
+
const a = texts[i];
|
|
750
|
+
const b = texts[j];
|
|
751
|
+
if (rectIntersect(shrinkBox(a, 1), shrinkBox(b, 1))) {
|
|
752
|
+
findings.push({
|
|
753
|
+
code: 'label-over-label',
|
|
754
|
+
line: Math.min(a.line, b.line),
|
|
755
|
+
detail: `"${a.text}" overlaps "${b.text}"`,
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 3. label-over-box (a text box straddling the border of a node it does
|
|
762
|
+
// not belong to) and text-overflows-box (a node's own text wider or
|
|
763
|
+
// taller than the node, past the 1-unit tolerance)
|
|
764
|
+
for (const text of texts) {
|
|
765
|
+
for (const node of nodes) {
|
|
766
|
+
if (node === text.owner) continue;
|
|
767
|
+
|
|
768
|
+
if (rectIntersect(shrinkBox(text, 1), node)) {
|
|
769
|
+
findings.push({
|
|
770
|
+
code: 'label-over-box',
|
|
771
|
+
line: text.line,
|
|
772
|
+
detail: `"${text.text}" straddles ${describeNode(node)}`,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (text.owner && !fullyInside(text, text.owner, 1)) {
|
|
778
|
+
const { owner } = text;
|
|
779
|
+
const over = Math.max(owner.x0 - text.x0, text.x1 - owner.x1, owner.y0 - text.y0, text.y1 - owner.y1);
|
|
780
|
+
const where =
|
|
781
|
+
owner.title === text.text
|
|
782
|
+
? `its own ${fmt(owner.x1 - owner.x0)}×${fmt(owner.y1 - owner.y0)} box`
|
|
783
|
+
: describeNode(owner);
|
|
784
|
+
findings.push({
|
|
785
|
+
code: 'text-overflows-box',
|
|
786
|
+
line: text.line,
|
|
787
|
+
detail: `"${text.text}" overflows ${where} by ${fmt(over)}`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// 4. edge-through-label (excluding a free text's own edge)
|
|
793
|
+
for (const edge of edges) {
|
|
794
|
+
for (const text of texts) {
|
|
795
|
+
if (text.ownEdge === edge) continue;
|
|
796
|
+
|
|
797
|
+
// A node's own text under an edge that does not attach to the node is
|
|
798
|
+
// the edge-through-box finding already reported — naming the title too
|
|
799
|
+
// would print one defect twice.
|
|
800
|
+
if (text.owner && !isAttached(edge, text.owner)) continue;
|
|
801
|
+
|
|
802
|
+
if (crossesBox(edge, shrinkBox(text, 1))) {
|
|
803
|
+
findings.push({
|
|
804
|
+
code: 'edge-through-label',
|
|
805
|
+
line: edge.line,
|
|
806
|
+
detail: `${describeEdge(edge)} crosses label "${text.text}"`,
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// 5. unlabeled-edge
|
|
813
|
+
const labeled = new Set(texts.filter((t) => t.ownEdge).map((t) => t.ownEdge));
|
|
814
|
+
for (const edge of edges) {
|
|
815
|
+
if (!labeled.has(edge)) {
|
|
816
|
+
findings.push({
|
|
817
|
+
code: 'unlabeled-edge',
|
|
818
|
+
line: edge.line,
|
|
819
|
+
detail: `${describeEdge(edge)} has no label within 24px`,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// 6. text-outside-canvas
|
|
825
|
+
if (viewBox) {
|
|
826
|
+
const canvas = `${viewBox.minX} ${viewBox.minY} ${viewBox.w} ${viewBox.h}`;
|
|
827
|
+
|
|
828
|
+
for (const text of texts) {
|
|
829
|
+
const fully = fullyInside(text, viewBox, 0);
|
|
830
|
+
if (!fully) {
|
|
831
|
+
findings.push({
|
|
832
|
+
code: 'text-outside-canvas',
|
|
833
|
+
line: text.line,
|
|
834
|
+
detail: `"${text.text}" falls outside the canvas (viewBox ${canvas})`,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return findings;
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
// Public API
|
|
845
|
+
// ---------------------------------------------------------------------------
|
|
846
|
+
|
|
847
|
+
// Finds every <svg>…</svg> block in `source` and runs all seven checks
|
|
848
|
+
// against each. Returns Finding[] = { code, line, detail }[], sorted by line
|
|
849
|
+
// then code.
|
|
850
|
+
export const checkSvg = (rawSource) => {
|
|
851
|
+
// The kit's own marker block carries `<!-- usage: <line … /> -->`, and the
|
|
852
|
+
// kit says to paste it verbatim — scanned raw, that comment is a phantom
|
|
853
|
+
// zero-length edge at the origin and every diagram fails "clean". Blank
|
|
854
|
+
// comments character-for-character (newlines kept) so line numbers hold.
|
|
855
|
+
const source = rawSource.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, ' '));
|
|
856
|
+
const findLine = makeLineFinder(source);
|
|
857
|
+
const styles = parseStyleFontSizes(source);
|
|
858
|
+
let curveWarned = false;
|
|
859
|
+
const notify = (msg) => process.stderr.write(msg + '\n');
|
|
860
|
+
notify.curve = () => {
|
|
861
|
+
if (curveWarned) return;
|
|
862
|
+
curveWarned = true;
|
|
863
|
+
notify('diagram-check: curve command (C/S/Q/T/A) approximated as its endpoint only');
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const findings = [];
|
|
867
|
+
const blockRe = /<svg\b[^>]*>[\s\S]*?<\/svg>/g;
|
|
868
|
+
let m;
|
|
869
|
+
|
|
870
|
+
while ((m = blockRe.exec(source))) {
|
|
871
|
+
const parsed = walkBlock(source, m.index, m[0], findLine, notify, styles);
|
|
872
|
+
findings.push(...findingsForBlock(parsed));
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
findings.sort((a, b) => a.line - b.line || a.code.localeCompare(b.code));
|
|
876
|
+
|
|
877
|
+
return findings;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// ---------------------------------------------------------------------------
|
|
881
|
+
// CLI
|
|
882
|
+
// ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
const runCli = (argv) => {
|
|
885
|
+
const jsonMode = argv.includes('--json');
|
|
886
|
+
const file = argv.find((a) => !a.startsWith('--'));
|
|
887
|
+
|
|
888
|
+
if (!file) {
|
|
889
|
+
process.stderr.write('usage: diagram-check.mjs <file.html|file.svg> [--json]\n');
|
|
890
|
+
|
|
891
|
+
return 2;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let source;
|
|
895
|
+
try {
|
|
896
|
+
source = fs.readFileSync(file, 'utf8');
|
|
897
|
+
} catch (err) {
|
|
898
|
+
process.stderr.write(`diagram-check: cannot read ${file}: ${err.message}\n`);
|
|
899
|
+
|
|
900
|
+
return 2;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (!/<svg\b[^>]*>[\s\S]*?<\/svg>/.test(source)) {
|
|
904
|
+
process.stderr.write(`diagram-check: no <svg> block found in ${file}\n`);
|
|
905
|
+
|
|
906
|
+
return 2;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const findings = checkSvg(source);
|
|
910
|
+
|
|
911
|
+
if (jsonMode) {
|
|
912
|
+
process.stdout.write(JSON.stringify({ file, findings }) + '\n');
|
|
913
|
+
} else if (findings.length === 0) {
|
|
914
|
+
process.stdout.write('clean\n');
|
|
915
|
+
} else {
|
|
916
|
+
for (const f of findings) process.stdout.write(`${f.code} line ${f.line} ${f.detail}\n`);
|
|
917
|
+
process.stdout.write(`${findings.length} finding(s)\n`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
return findings.length === 0 ? 0 : 1;
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
924
|
+
process.exit(runCli(process.argv.slice(2)));
|
|
925
|
+
}
|
|
@@ -8,8 +8,10 @@ of the `<style>` block, verbatim.
|
|
|
8
8
|
|
|
9
9
|
## Shared Conventions
|
|
10
10
|
|
|
11
|
-
- Canvas: `<svg viewBox="0 0 W H" style="max-width:100%">`
|
|
12
|
-
the page wraps it in the wireframe kit's `.frame` shell
|
|
11
|
+
- Canvas: `<svg viewBox="0 0 W H" font-size="13" style="max-width:100%">`
|
|
12
|
+
sized to content; the page wraps it in the wireframe kit's `.frame` shell
|
|
13
|
+
(either mode's). The `font-size` is the base every text inherits — see
|
|
14
|
+
Sizing below.
|
|
13
15
|
- Palette is token roles, referenced with `var(--…)` (CSS variables work in
|
|
14
16
|
inline SVG attributes): fills `var(--card)` / `var(--muted)`, strokes
|
|
15
17
|
`var(--border)`, edges `var(--subtle)`, text `var(--foreground)`. The one
|
|
@@ -19,6 +21,17 @@ of the `<style>` block, verbatim.
|
|
|
19
21
|
rows, cardinalities, edge labels (D2). 13–14px labels, 11px annotations
|
|
20
22
|
in `var(--muted-foreground)`. If a label doesn't fit its box, the box
|
|
21
23
|
grows — never shrink the font below 11px.
|
|
24
|
+
- Sizing: the base size goes on the `<svg>` tag (`font-size="13"`), every
|
|
25
|
+
other size as a `font-size` attribute on the `<text>` itself. Never a
|
|
26
|
+
`text { font-size }` rule in `<style>` — in the browser it overrides every
|
|
27
|
+
`font-size` attribute in the diagram, so the sizes you wrote are not the
|
|
28
|
+
sizes that render. The checker resolves sizes the way the browser does but
|
|
29
|
+
sees only attributes, a bare `text` rule and `.class` rules; a text it
|
|
30
|
+
cannot size is read at the browser's 16px and reported on stderr. Budget
|
|
31
|
+
width at 0.6 em per character (exact for mono, generous for regular sans,
|
|
32
|
+
a little tight for semibold titles): at 16px a 23-character title needs
|
|
33
|
+
more than a 210 box, and when it doesn't fit the box grows — the checker
|
|
34
|
+
reports `text-overflows-box`, never a smaller font.
|
|
22
35
|
- Arrowheads via one shared `<marker>`:
|
|
23
36
|
|
|
24
37
|
```html
|
|
@@ -28,12 +41,75 @@ of the `<style>` block, verbatim.
|
|
|
28
41
|
<path d="M0,0 L10,5 L0,10 z" fill="var(--subtle)"/>
|
|
29
42
|
</marker>
|
|
30
43
|
</defs>
|
|
31
|
-
<!-- usage: <line ... stroke="var(--subtle)" marker-end="url(#arrow)"/>
|
|
44
|
+
<!-- usage: <line ... stroke="var(--subtle)" marker-end="url(#arrow)"/> or
|
|
45
|
+
<path ... stroke="var(--subtle)" marker-end="url(#arrow)"/> — the marker
|
|
46
|
+
works the same on both elements. -->
|
|
32
47
|
```
|
|
33
48
|
|
|
34
49
|
- Every relationship line gets a label. An unlabeled arrow is a guess the
|
|
35
50
|
reader has to make.
|
|
36
51
|
|
|
52
|
+
## Layout and Routing
|
|
53
|
+
|
|
54
|
+
1. *Grid first.* Assign every node a column and a row before drawing anything.
|
|
55
|
+
Column pitch is box width plus a gutter of at least 72; row pitch is box
|
|
56
|
+
height plus a gutter of at least 72. Gutters are edge corridors — wide
|
|
57
|
+
enough for a label with its halo and two staggered edges. Boundaries wrap
|
|
58
|
+
whole column/row spans and are drawn behind their members.
|
|
59
|
+
2. *Edges are orthogonal paths.* `<path d="M … H … V … H …">` with the shared
|
|
60
|
+
arrow marker (it works on `<path>` exactly as on `<line>`). Never a
|
|
61
|
+
diagonal. An edge leaves the side of its source that faces the target and
|
|
62
|
+
enters the facing side of the target.
|
|
63
|
+
3. *Ports.* Each side of a box has three ports, at 25%, 50% and 75% of its
|
|
64
|
+
length. Two edges on the same side take different ports — edges never
|
|
65
|
+
share a port, so parallel edges never stack.
|
|
66
|
+
4. *Corridors only.* A segment may run only through gutters and across
|
|
67
|
+
boundaries, never through a box's footprint. If a third box sits in the
|
|
68
|
+
straight corridor between source and target, either move it to another row
|
|
69
|
+
or column (preferred — rearranging is free before the drawing exists) or
|
|
70
|
+
dog-leg through the adjacent gutter.
|
|
71
|
+
5. *Labels.* One label per segment, on the edge's longest free segment,
|
|
72
|
+
`--mono` 11px: on a horizontal segment it sits 4px above the line, on a
|
|
73
|
+
vertical segment it is centred on the line over its halo.
|
|
74
|
+
Every label gets a halo so a crossing edge stays legible: a stroke-less
|
|
75
|
+
`<rect>` in the fill the label sits on (`var(--background)` on open
|
|
76
|
+
canvas, `var(--muted)` inside a filled boundary) drawn behind the text —
|
|
77
|
+
the checker ignores stroke-less rects, so the halo is never mistaken for a
|
|
78
|
+
node. Size the halo from the same budget as the label — characters × 11 ×
|
|
79
|
+
0.6, plus 4px each side — a halo narrower than its label lets the crossing
|
|
80
|
+
edge show through the ends. Two labels never share a corridor position:
|
|
81
|
+
stagger by 14px or move one to another segment.
|
|
82
|
+
6. *Fan-in is a smell.* More than four edges into one box, or more than three
|
|
83
|
+
edges in one gutter, means the diagram is answering two questions — split
|
|
84
|
+
it (same rule as "Choosing the Shape").
|
|
85
|
+
7. *Check before delivering.* Run
|
|
86
|
+
`node /PATH/TO/blueprint/references/diagram-check.mjs <file>` (the
|
|
87
|
+
`/PATH/TO/` idiom is the one `plan-review` uses for its scripts — the
|
|
88
|
+
skill's install location). A finding is a defect in the drawing, never a
|
|
89
|
+
tolerance to argue with: fix, re-run, deliver only `clean`. If `node` is
|
|
90
|
+
unavailable, say the check was skipped — never silently.
|
|
91
|
+
|
|
92
|
+
| Code | Meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `edge-through-box` | an edge segment crosses a node rect it doesn't attach to |
|
|
95
|
+
| `label-over-label` | two text boxes intersect |
|
|
96
|
+
| `label-over-box` | a text box straddles the border of a node it doesn't belong to |
|
|
97
|
+
| `text-overflows-box` | a node's own text is wider or taller than the node |
|
|
98
|
+
| `edge-through-label` | an edge crosses a text box that isn't its own label |
|
|
99
|
+
| `unlabeled-edge` | no text within 24px of the edge |
|
|
100
|
+
| `text-outside-canvas` | text escapes the viewBox |
|
|
101
|
+
|
|
102
|
+
Two boxes on different rows and columns, joined by a three-segment path
|
|
103
|
+
with one haloed label and distinct ports:
|
|
104
|
+
|
|
105
|
+
```html
|
|
106
|
+
<!-- orders → invoices: leave the right side at the 50% port, run the gutter,
|
|
107
|
+
enter the left side at its 25% port. Label centred on the vertical segment, haloed. -->
|
|
108
|
+
<path d="M 240 92 H 300 V 180 H 340" fill="none" stroke="var(--subtle)" marker-end="url(#arrow)"/>
|
|
109
|
+
<rect x="262" y="120" width="76" height="14" fill="var(--background)"/>
|
|
110
|
+
<text x="300" y="131" text-anchor="middle" font-size="11" fill="var(--muted-foreground)">1..* invoices</text>
|
|
111
|
+
```
|
|
112
|
+
|
|
37
113
|
## The Specificity Rule
|
|
38
114
|
|
|
39
115
|
Every label must name something real from *this* design — a path, a command, a
|