@stacksjs/defaults 0.75.361 → 0.75.362
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/ai/skills/stacks-ai/SKILL.md +22 -0
- package/ai/skills/stacks-native/CATALOG.md +2 -2
- package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
- package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
- package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
- package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
- package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
- package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
- package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
- package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
- package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
- package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
- package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
- package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
- package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
- package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
- package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
- package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
- package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
- package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
- package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
- package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
- package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
- package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
- package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
- package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
- package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
- package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
- package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
- package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
- package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
- package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
- package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
- package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
- package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
- package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
- package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
- package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
- package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
- package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
- package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
- package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
- package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
- package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
- package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
- package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
- package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
- package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
- package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
- package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
- package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
- package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
- package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
- package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
- package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
- package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
- package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
- package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
- package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
- package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
- package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
- package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
- package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
- package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
- package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
- package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
- package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
- package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
- package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
- package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
- package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
- package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
- package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
- package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
- package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
- package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
- package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
- package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
- package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
- package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
- package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
- package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
- package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
- package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
- package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
- package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
- package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
- package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
- package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
- package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
- package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
- package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
- package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
- package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +2 -2
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import { isExplainerHtml, checkExplainerHtml } from '../explainer/check.mjs';
|
|
5
|
+
import { analyzeSvgPath } from '../renderers/shared/svg-path-analysis.mjs';
|
|
6
|
+
import { textUnits } from '../renderers/shared/utils.mjs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
4
8
|
import path from 'node:path';
|
|
5
|
-
import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
|
|
9
|
+
import { collectAmbiguousCorridors, collectArrowheadCollisions, collectBorderRuns, collectLabelCanvasOverflow, collectLabelRouteClearance, collectRouteRhythmIssues, describeLabelCanvasOverflow, formatRect, forwardCollinearAnalysisSegments, minimumLabelRouteClearance, normalizeRoutePoints, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
|
|
6
10
|
import {
|
|
7
11
|
DESKTOP_READABILITY_VIEWPORT,
|
|
8
12
|
DESKTOP_READER_DIAGRAM_WIDTH,
|
|
13
|
+
DECLARED_WIDE_READER_CONTRACT,
|
|
14
|
+
declaredWideReadabilityBudget,
|
|
9
15
|
MIN_PROJECTED_NODE_TEXT_PX,
|
|
16
|
+
describeFixedWidthOverflow,
|
|
17
|
+
predictedFixedWidthOverflow,
|
|
10
18
|
projectedNodeTextPx,
|
|
11
19
|
} from '../renderers/shared/desktop-readability.mjs';
|
|
12
20
|
|
|
@@ -19,8 +27,11 @@ if (!input || input === '-h' || input === '--help') {
|
|
|
19
27
|
|
|
20
28
|
const htmlPath = path.resolve(input);
|
|
21
29
|
let html;
|
|
30
|
+
let artifact;
|
|
22
31
|
try {
|
|
23
|
-
|
|
32
|
+
const bytes = fs.readFileSync(htmlPath);
|
|
33
|
+
html = bytes.toString('utf8');
|
|
34
|
+
artifact = { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength };
|
|
24
35
|
} catch (err) {
|
|
25
36
|
console.error(JSON.stringify({
|
|
26
37
|
ok: false,
|
|
@@ -30,7 +41,15 @@ try {
|
|
|
30
41
|
process.exit(1);
|
|
31
42
|
}
|
|
32
43
|
|
|
44
|
+
if (isExplainerHtml(html)) {
|
|
45
|
+
const result = checkExplainerHtml(html, { file: htmlPath });
|
|
46
|
+
console.log(JSON.stringify(result, null, 2));
|
|
47
|
+
process.exit(result.ok ? 0 : 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
33
50
|
const checks = [];
|
|
51
|
+
const pathDiagnostics = [];
|
|
52
|
+
const pathAnalyses = new Map();
|
|
34
53
|
let composition = {
|
|
35
54
|
schemaVersion: 1,
|
|
36
55
|
profile: 'standard',
|
|
@@ -38,10 +57,12 @@ let composition = {
|
|
|
38
57
|
summary: { errors: 0, warnings: 0 },
|
|
39
58
|
metrics: {
|
|
40
59
|
properCrossings: 0,
|
|
60
|
+
resolvedCrossovers: 0,
|
|
41
61
|
ambiguousCorridors: 0,
|
|
42
62
|
containerBorderRuns: 0,
|
|
43
63
|
labelRouteClearanceIssues: 0,
|
|
44
64
|
minLabelRouteClearance: null,
|
|
65
|
+
labelCanvasOverflowIssues: 0,
|
|
45
66
|
maxBends: 0,
|
|
46
67
|
routesOverSuggestedBends: 0,
|
|
47
68
|
maxStretch: null,
|
|
@@ -59,6 +80,34 @@ let composition = {
|
|
|
59
80
|
issues: [],
|
|
60
81
|
};
|
|
61
82
|
|
|
83
|
+
const NON_FINITE_TOKEN = /\b(?:NaN|undefined|Infinity)\b/;
|
|
84
|
+
// Consume comments/CDATA as whole tokens, including any tag-like prose.
|
|
85
|
+
const SVG_TAG_TOKEN = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<(\/?)([A-Za-z][\w:-]*)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>/g;
|
|
86
|
+
const AUTOMATIC_CROSSOVER_UNDERLAY_TAG = /<path\b[^>]*\bdata-graph-role="automatic-crossover-underlay"[^>]*\/>/i;
|
|
87
|
+
const HTML_VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
88
|
+
const SVG_HTML_INTEGRATION_POINTS = new Set(['foreignobject', 'desc', 'title']);
|
|
89
|
+
const HTML_ATTRIBUTE = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
90
|
+
const readerContract = readerContractFromHtml(html);
|
|
91
|
+
const NUMERIC_ATTRS = new Set([
|
|
92
|
+
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy',
|
|
93
|
+
'fr', 'width', 'height', 'd', 'points', 'pathlength', 'transform', 'viewbox', 'offset',
|
|
94
|
+
'opacity', 'fill-opacity', 'flood-opacity', 'stop-opacity', 'stroke-opacity', 'font-size',
|
|
95
|
+
'font-size-adjust', 'font-weight', 'letter-spacing', 'word-spacing', 'kerning', 'stroke-width',
|
|
96
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'textlength', 'startoffset',
|
|
97
|
+
'rotate', 'markerwidth', 'markerheight', 'refx', 'refy', 'orient', 'patterntransform',
|
|
98
|
+
'gradienttransform', 'filterres', 'stddeviation', 'basefrequency', 'numoctaves', 'seed',
|
|
99
|
+
'surfacescale', 'diffuseconstant', 'specularconstant', 'specularexponent',
|
|
100
|
+
'limitingconeangle', 'azimuth', 'elevation', 'pointsatx', 'pointsaty', 'pointsatz',
|
|
101
|
+
'kernelmatrix', 'order', 'divisor', 'bias', 'targetx', 'targety', 'kernelunitlength',
|
|
102
|
+
'scale', 'radius', 'k1', 'k2', 'k3', 'k4', 'tablevalues', 'slope', 'intercept',
|
|
103
|
+
'amplitude', 'exponent',
|
|
104
|
+
]);
|
|
105
|
+
const ELEMENT_NUMERIC_ATTRS = new Map([
|
|
106
|
+
['fecolormatrix', new Set(['values'])],
|
|
107
|
+
['fepointlight', new Set(['z'])],
|
|
108
|
+
['fespotlight', new Set(['z'])],
|
|
109
|
+
]);
|
|
110
|
+
|
|
62
111
|
function addCheck(name, ok, details = []) {
|
|
63
112
|
checks.push({ name, ok, details });
|
|
64
113
|
}
|
|
@@ -71,19 +120,35 @@ if (svgMatches.length === 1) {
|
|
|
71
120
|
const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
|
|
72
121
|
const svgAttrs = parseAttrs(svgRoot);
|
|
73
122
|
const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
|
|
123
|
+
const workflowV2 = svgAttrs['data-layout-contract'] === 'readable-v2';
|
|
74
124
|
const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
|
|
75
|
-
|
|
125
|
+
const nonFiniteAttrs = collectNonFiniteAttrs(svg);
|
|
126
|
+
addCheck('finite_svg', nonFiniteAttrs.length === 0, nonFiniteAttrs);
|
|
76
127
|
const legendStart = svg.indexOf('<!-- Legend -->');
|
|
77
128
|
const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
|
|
78
|
-
const
|
|
79
|
-
const
|
|
129
|
+
const desktopReadability = collectDesktopReadability(svgAttrs, beforeLegend, readerContract);
|
|
130
|
+
const desktopReadabilityIssue = desktopReadability.issue;
|
|
131
|
+
// The browser gate measures this later; the geometry is already certain here.
|
|
132
|
+
const viewportHeightIssue = predictedFixedWidthOverflow({
|
|
133
|
+
viewBoxWidth: viewBoxSize(svgAttrs)[0],
|
|
134
|
+
viewBoxHeight: viewBoxSize(svgAttrs)[1],
|
|
135
|
+
readerFit: svgAttrs['data-reader-fit'] || null,
|
|
136
|
+
diagramType: svgAttrs['data-diagram-type'] || null,
|
|
137
|
+
});
|
|
138
|
+
const arrows = collectArrows(beforeLegend, workflowV2);
|
|
80
139
|
const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
|
|
81
140
|
addCheck(
|
|
82
141
|
'orthogonal_arrows',
|
|
83
142
|
diagonal.length === 0,
|
|
84
|
-
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
|
|
143
|
+
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`),
|
|
85
144
|
);
|
|
86
|
-
const
|
|
145
|
+
const measuredRelationshipCrossings = collectRelationshipCrossings(arrows, workflowV2);
|
|
146
|
+
const resolvedCrossovers = measuredRelationshipCrossings.filter((hit) => (
|
|
147
|
+
hit.left.crossoverHalo && hit.right.crossoverHalo
|
|
148
|
+
));
|
|
149
|
+
const relationshipCrossings = measuredRelationshipCrossings.filter((hit) => (
|
|
150
|
+
!hit.left.crossoverHalo || !hit.right.crossoverHalo
|
|
151
|
+
));
|
|
87
152
|
const compositionFrames = collectCompositionFrames(beforeLegend);
|
|
88
153
|
const containerBorderRuns = collectBorderRuns({
|
|
89
154
|
routedRelations: arrows
|
|
@@ -96,40 +161,74 @@ if (svgMatches.length === 1) {
|
|
|
96
161
|
frames: compositionFrames,
|
|
97
162
|
});
|
|
98
163
|
const routedRelationships = arrows
|
|
99
|
-
.filter((arrow) => arrow.from && arrow.to && arrow.
|
|
164
|
+
.filter((arrow) => arrow.from && arrow.to && arrow.routeSubpaths.some(points => points.length > 1))
|
|
100
165
|
.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
|
|
166
|
+
const nodeRects = svgAttrs.transform ? [] : collectUntransformedNodeRects(beforeLegend);
|
|
167
|
+
const routeMetrics = subpathBudgetMetrics(routedRelationships);
|
|
168
|
+
// Architecture marks only one of its two Reader fits with its type.
|
|
169
|
+
const crowdedSides = svgAttrs['data-diagram-type'] === 'architecture' || svgAttrs['data-reader-primary-text'] === '14'
|
|
170
|
+
? crowdedNodeSides(arrows, nodeRects) : [];
|
|
171
|
+
const routeRhythmIssues = subpathRhythmIssues(routedRelationships);
|
|
172
|
+
const ambiguousCorridors = subpathCorridors({
|
|
173
|
+
routedRelations: routedRelationships,
|
|
174
|
+
includeSharedEndpoints: (left, right) => workflowV2 || (left.independentPorts && right.independentPorts),
|
|
175
|
+
allowShortWorkflowTrunks: workflowV2,
|
|
176
|
+
includeSharedEndpointCounterflow: (left, right) => left.automaticWorkflowRoute && right.automaticWorkflowRoute,
|
|
177
|
+
});
|
|
178
|
+
const arrowheadCollisions = collectArrowheadCollisions({
|
|
179
|
+
routedRelations: routedRelationships.filter((entry) => workflowV2 || entry.relation.independentPorts)
|
|
180
|
+
.flatMap(entry => routeParts(entry).filter(part => part.targetEndpoint)),
|
|
181
|
+
allowShortWorkflowTrunks: workflowV2,
|
|
182
|
+
});
|
|
104
183
|
const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
|
|
184
|
+
const leadingSpace = collectArchitectureLeadingSpace({
|
|
185
|
+
svgAttrs, fragment: beforeLegend, nodeRects, frames: compositionFrames,
|
|
186
|
+
arrows, labels: relationshipLabels,
|
|
187
|
+
});
|
|
188
|
+
const sequenceColumnSpace = collectSequenceColumnSpace({ svgAttrs, fragment: beforeLegend, nodeRects, arrows });
|
|
189
|
+
const sequenceNoteBorders = collectSequenceNoteBorders({ svgAttrs, fragment: beforeLegend, frames: compositionFrames, arrows });
|
|
105
190
|
const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
|
|
106
|
-
const labelRouteMeasurements =
|
|
191
|
+
const labelRouteMeasurements = subpathLabelClearance({
|
|
107
192
|
labels: relationshipLabels,
|
|
108
193
|
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
109
194
|
threshold: Number.MAX_VALUE,
|
|
110
195
|
});
|
|
111
|
-
const labelRouteClearance =
|
|
196
|
+
const labelRouteClearance = subpathLabelClearance({
|
|
112
197
|
labels: relationshipLabels,
|
|
113
198
|
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
114
199
|
threshold: labelClearanceThreshold,
|
|
115
200
|
});
|
|
201
|
+
// The renderers bound their own label rects, but `check` also re-measures an
|
|
202
|
+
// artifact it did not produce; see collectLabelCanvasOverflow in
|
|
203
|
+
// shared/geometry.mjs.
|
|
204
|
+
const labelCanvasOverflow = collectLabelCanvasOverflow({
|
|
205
|
+
labels: relationshipLabels,
|
|
206
|
+
viewBox: viewBoxRect(svgAttrs),
|
|
207
|
+
});
|
|
116
208
|
const crossingIsError = qualityProfile === 'showcase';
|
|
117
209
|
const corridorIsError = qualityProfile === 'showcase';
|
|
118
210
|
const rhythmIsError = qualityProfile === 'showcase';
|
|
119
211
|
const labelClearanceIsError = qualityProfile === 'showcase';
|
|
212
|
+
const labelContainmentIsError = qualityProfile === 'showcase';
|
|
120
213
|
const desktopReadabilityIsError = qualityProfile === 'showcase';
|
|
214
|
+
// Certain geometry, but new: surface it as evidence first (CONTRIBUTING.md#product-and-compatibility-contracts).
|
|
215
|
+
const viewportHeightIsError = false;
|
|
121
216
|
const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
|
|
122
217
|
+ (crossingIsError ? relationshipCrossings.length : 0)
|
|
123
|
-
+ (corridorIsError ? ambiguousCorridors.length : 0)
|
|
218
|
+
+ (corridorIsError ? ambiguousCorridors.length + arrowheadCollisions.length : 0)
|
|
124
219
|
+ (labelClearanceIsError ? labelRouteClearance.length : 0)
|
|
220
|
+
+ (labelContainmentIsError ? labelCanvasOverflow.length : 0)
|
|
125
221
|
+ (rhythmIsError ? routeRhythmIssues.length : 0)
|
|
126
|
-
+ (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0)
|
|
222
|
+
+ (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0)
|
|
223
|
+
+ (viewportHeightIsError && viewportHeightIssue ? 1 : 0);
|
|
127
224
|
const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
|
|
128
225
|
+ (crossingIsError ? 0 : relationshipCrossings.length)
|
|
129
|
-
+ (corridorIsError ? 0 : ambiguousCorridors.length)
|
|
226
|
+
+ (corridorIsError ? 0 : ambiguousCorridors.length + arrowheadCollisions.length)
|
|
130
227
|
+ (labelClearanceIsError ? 0 : labelRouteClearance.length)
|
|
228
|
+
+ (labelContainmentIsError ? 0 : labelCanvasOverflow.length)
|
|
131
229
|
+ (rhythmIsError ? 0 : routeRhythmIssues.length)
|
|
132
|
-
+ (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1)
|
|
230
|
+
+ (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1)
|
|
231
|
+
+ (viewportHeightIsError || !viewportHeightIssue ? 0 : 1);
|
|
133
232
|
composition = {
|
|
134
233
|
schemaVersion: 1,
|
|
135
234
|
profile: qualityProfile,
|
|
@@ -140,17 +239,48 @@ if (svgMatches.length === 1) {
|
|
|
140
239
|
},
|
|
141
240
|
metrics: {
|
|
142
241
|
properCrossings: relationshipCrossings.length,
|
|
242
|
+
resolvedCrossovers: resolvedCrossovers.length,
|
|
143
243
|
ambiguousCorridors: ambiguousCorridors.length,
|
|
244
|
+
arrowheadCollisions: arrowheadCollisions.length,
|
|
144
245
|
containerBorderRuns: containerBorderRuns.length,
|
|
145
246
|
labelRouteClearanceIssues: labelRouteClearance.length,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
: null,
|
|
247
|
+
labelCanvasOverflowIssues: labelCanvasOverflow.length,
|
|
248
|
+
minLabelRouteClearance: minimumLabelRouteClearance(labelRouteMeasurements),
|
|
149
249
|
desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
|
|
150
|
-
|
|
250
|
+
viewportHeightIssues: viewportHeightIssue ? 1 : 0,
|
|
251
|
+
minProjectedNodeTextPx: desktopReadability.evidence.minimumProjectedTextPx,
|
|
151
252
|
...roundedRouteMetrics(routeMetrics),
|
|
152
253
|
},
|
|
153
254
|
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
|
255
|
+
// Perceptual review needs the affected relationships, not just a total.
|
|
256
|
+
// These are review evidence, not new pass/fail thresholds: a short, clear
|
|
257
|
+
// crossover can be preferable to a long crossing-free detour.
|
|
258
|
+
routeReview: {
|
|
259
|
+
crossings: resolvedCrossovers.map((hit) => {
|
|
260
|
+
const shared = [hit.left.from, hit.left.to].find((id) => id && (id === hit.right.from || id === hit.right.to));
|
|
261
|
+
return {
|
|
262
|
+
left: relationshipRecord(hit.left),
|
|
263
|
+
right: relationshipRecord(hit.right),
|
|
264
|
+
point: hit.point,
|
|
265
|
+
...(shared ? { sharedNode: shared } : {}),
|
|
266
|
+
};
|
|
267
|
+
}),
|
|
268
|
+
...(crowdedSides.length ? { crowdedSides } : {}),
|
|
269
|
+
detours: routedRelationships.flatMap((entry) => {
|
|
270
|
+
const metrics = subpathBudgetMetrics([entry]);
|
|
271
|
+
const blockers = directCorridorBlockers(entry.relation, nodeRects);
|
|
272
|
+
return metrics.routesOverSuggestedBends || metrics.routesOverSuggestedStretch ? [{
|
|
273
|
+
relationship: relationshipRecord(entry.relation),
|
|
274
|
+
bends: metrics.maxBends,
|
|
275
|
+
stretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
|
|
276
|
+
...(blockers.length ? { directCorridorBlockers: blockers } : {}),
|
|
277
|
+
}] : [];
|
|
278
|
+
}),
|
|
279
|
+
},
|
|
280
|
+
leadingSpace,
|
|
281
|
+
...(sequenceColumnSpace ? { sequenceColumnSpace } : {}),
|
|
282
|
+
...(sequenceNoteBorders.length ? { sequenceNoteBorders } : {}),
|
|
283
|
+
desktopReadability: desktopReadability.evidence,
|
|
154
284
|
issues: [
|
|
155
285
|
...containerBorderRuns.map((hit) => ({
|
|
156
286
|
severity: qualityGatesEnforced ? 'error' : 'warning',
|
|
@@ -177,6 +307,20 @@ if (svgMatches.length === 1) {
|
|
|
177
307
|
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
|
178
308
|
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
|
179
309
|
})),
|
|
310
|
+
...labelCanvasOverflow.map((hit) => {
|
|
311
|
+
const label = hit.label?.label || hit.relation?.label || '';
|
|
312
|
+
return {
|
|
313
|
+
severity: labelContainmentIsError ? 'error' : 'warning',
|
|
314
|
+
code: 'composition/label-canvas-containment',
|
|
315
|
+
label,
|
|
316
|
+
relationship: relationshipRecord(hit.relation),
|
|
317
|
+
labelRect: roundedRect(hit.rect),
|
|
318
|
+
viewBox: hit.viewBox,
|
|
319
|
+
viewBoxOrigin: hit.viewBoxOrigin,
|
|
320
|
+
overflowPx: hit.overflowPx,
|
|
321
|
+
detail: `[composition/label-canvas-containment] ${qualityProfile} label "${label}" on ${relationshipName(hit.relation)} extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}${hit.viewBoxOrigin.some(Boolean) ? ` at ${hit.viewBoxOrigin[0]},${hit.viewBoxOrigin[1]}` : ''}) — use renderer-supported label controls (shorten the label or reorder participants for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or enlarge meta.viewBox.`,
|
|
322
|
+
};
|
|
323
|
+
}),
|
|
180
324
|
...relationshipCrossings.map((hit) => ({
|
|
181
325
|
severity: crossingIsError ? 'error' : 'warning',
|
|
182
326
|
code: 'composition/proper-crossing',
|
|
@@ -195,6 +339,15 @@ if (svgMatches.length === 1) {
|
|
|
195
339
|
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
|
196
340
|
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
|
197
341
|
})),
|
|
342
|
+
...arrowheadCollisions.map((hit) => ({
|
|
343
|
+
severity: corridorIsError ? 'error' : 'warning',
|
|
344
|
+
code: 'composition/arrowhead-collision',
|
|
345
|
+
relationship: relationshipRecord(hit.left.relation),
|
|
346
|
+
otherRelationship: relationshipRecord(hit.right.relation),
|
|
347
|
+
distancePx: hit.distance,
|
|
348
|
+
minimumPx: hit.minimum,
|
|
349
|
+
endpoints: [hit.left.tip, hit.right.tip],
|
|
350
|
+
})),
|
|
198
351
|
...routeRhythmIssues.map((hit) => ({
|
|
199
352
|
severity: rhythmIsError ? 'error' : 'warning',
|
|
200
353
|
code: hit.code,
|
|
@@ -208,9 +361,16 @@ if (svgMatches.length === 1) {
|
|
|
208
361
|
...(desktopReadabilityIssue ? [{
|
|
209
362
|
severity: desktopReadabilityIsError ? 'error' : 'warning',
|
|
210
363
|
code: 'composition/desktop-readability',
|
|
364
|
+
...(desktopReadabilityIssue.nodeId ? { nodeId: desktopReadabilityIssue.nodeId } : {}),
|
|
365
|
+
owner: desktopReadabilityIssue.owner,
|
|
211
366
|
viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
|
|
212
367
|
viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
|
|
213
|
-
availableDiagramWidth:
|
|
368
|
+
availableDiagramWidth: desktopReadability.evidence.availableDiagramWidth,
|
|
369
|
+
budgetBasis: desktopReadability.evidence.budgetBasis,
|
|
370
|
+
readerContract: desktopReadability.evidence.readerContract,
|
|
371
|
+
requestedTargetPx: desktopReadability.evidence.requestedTargetPx,
|
|
372
|
+
requestedTargetMet: desktopReadability.evidence.requestedTargetMet,
|
|
373
|
+
budgetLimit: desktopReadability.evidence.limit,
|
|
214
374
|
viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
|
|
215
375
|
scale: desktopReadabilityIssue.scale,
|
|
216
376
|
text: desktopReadabilityIssue.text,
|
|
@@ -219,13 +379,22 @@ if (svgMatches.length === 1) {
|
|
|
219
379
|
projectedFontPx: desktopReadabilityIssue.projectedFontPx,
|
|
220
380
|
minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
221
381
|
}] : []),
|
|
382
|
+
...(viewportHeightIssue ? [{
|
|
383
|
+
severity: viewportHeightIsError ? 'error' : 'warning',
|
|
384
|
+
code: 'composition/viewport-height',
|
|
385
|
+
readerFit: svgAttrs['data-reader-fit'] || null,
|
|
386
|
+
viewBoxWidth: viewBoxSize(svgAttrs)[0],
|
|
387
|
+
viewBoxHeight: viewBoxSize(svgAttrs)[1],
|
|
388
|
+
...viewportHeightIssue,
|
|
389
|
+
detail: `[composition/viewport-height] ${describeFixedWidthOverflow({ ...viewportHeightIssue, viewBoxWidth: viewBoxSize(svgAttrs)[0], viewBoxHeight: viewBoxSize(svgAttrs)[1] })}`,
|
|
390
|
+
}] : []),
|
|
222
391
|
],
|
|
223
392
|
};
|
|
224
393
|
addCheck(
|
|
225
394
|
'label_route_clearance',
|
|
226
395
|
!labelClearanceIsError || labelRouteClearance.length === 0,
|
|
227
396
|
labelRouteClearance.map((hit) => (
|
|
228
|
-
`[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px)
|
|
397
|
+
`[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
|
|
229
398
|
)),
|
|
230
399
|
);
|
|
231
400
|
addCheck(
|
|
@@ -237,10 +406,12 @@ if (svgMatches.length === 1) {
|
|
|
237
406
|
);
|
|
238
407
|
addCheck(
|
|
239
408
|
'relationship_corridors',
|
|
240
|
-
!corridorIsError || ambiguousCorridors.length === 0,
|
|
241
|
-
ambiguousCorridors.map((hit) => (
|
|
409
|
+
!corridorIsError || ambiguousCorridors.length + arrowheadCollisions.length === 0,
|
|
410
|
+
[...ambiguousCorridors.map((hit) => (
|
|
242
411
|
`[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
|
|
243
|
-
)),
|
|
412
|
+
)), ...arrowheadCollisions.map((hit) => (
|
|
413
|
+
`[composition/arrowhead-collision] ${qualityProfile} ${relationshipName(hit.left.relation)} and ${relationshipName(hit.right.relation)} have incoming arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`
|
|
414
|
+
))],
|
|
244
415
|
);
|
|
245
416
|
addCheck(
|
|
246
417
|
'container_border_runs',
|
|
@@ -271,35 +442,88 @@ if (svgMatches.length === 1) {
|
|
|
271
442
|
}
|
|
272
443
|
}
|
|
273
444
|
|
|
445
|
+
if (pathDiagnostics.length) addCheck('svg_path_data', false, pathDiagnostics.map(item => item.message));
|
|
446
|
+
|
|
274
447
|
const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
|
|
275
|
-
console.log(JSON.stringify({ ok, file: htmlPath, checks, composition
|
|
448
|
+
console.log(JSON.stringify({ ok, file: htmlPath, artifact, checks, composition,
|
|
449
|
+
...(pathDiagnostics.length ? { diagnostics: pathDiagnostics } : {}),
|
|
450
|
+
}, null, 2));
|
|
276
451
|
// Let pending stdout writes drain: large receipts are asynchronous when piped.
|
|
277
452
|
process.exitCode = ok ? 0 : 1;
|
|
278
453
|
|
|
279
|
-
function collectArrows(fragment) {
|
|
454
|
+
function collectArrows(fragment, useActualPoints = false) {
|
|
280
455
|
const arrows = [];
|
|
281
456
|
let index = 0;
|
|
457
|
+
let pathIndex = 0;
|
|
458
|
+
let previousTag = null;
|
|
459
|
+
let previousTagEnd = 0;
|
|
282
460
|
|
|
283
|
-
for (const tag of fragment.matchAll(
|
|
461
|
+
for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
462
|
+
const name = tag[2]?.toLowerCase();
|
|
463
|
+
if (tag[1] || !['path', 'line'].includes(name)) continue;
|
|
464
|
+
if (name === 'path') pathIndex += 1;
|
|
465
|
+
const tagStart = tag.index;
|
|
284
466
|
const raw = tag[0];
|
|
467
|
+
const gap = previousTag ? fragment.slice(previousTagEnd, tagStart) : '';
|
|
468
|
+
const precedingUnderlay = previousTag
|
|
469
|
+
&& /^\s*$/.test(gap)
|
|
470
|
+
&& previousTag.name === 'path'
|
|
471
|
+
&& previousTag.underlayAttrs;
|
|
472
|
+
previousTag = {
|
|
473
|
+
name: name,
|
|
474
|
+
underlayAttrs: name === 'path' && AUTOMATIC_CROSSOVER_UNDERLAY_TAG.test(raw)
|
|
475
|
+
? parseAttrs(raw)
|
|
476
|
+
: null,
|
|
477
|
+
};
|
|
478
|
+
previousTagEnd = tagStart + raw.length;
|
|
285
479
|
if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
|
|
286
480
|
if (!/\bmarker-end=/.test(raw)) continue;
|
|
287
481
|
const attrs = parseAttrs(raw);
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
482
|
+
const routeStrokeWidth = numberAttr(attrs, 'stroke-width');
|
|
483
|
+
const underlayStrokeWidth = precedingUnderlay ? numberAttr(precedingUnderlay, 'stroke-width') : NaN;
|
|
484
|
+
const verifiedCrossoverHalo = attrs['data-composition-crossover'] === 'halo'
|
|
485
|
+
&& precedingUnderlay?.d === attrs.d
|
|
486
|
+
&& precedingUnderlay?.fill === 'none'
|
|
487
|
+
&& precedingUnderlay?.stroke === 'var(--mask)'
|
|
488
|
+
&& precedingUnderlay?.['pointer-events'] === 'none'
|
|
489
|
+
&& Number.isFinite(routeStrokeWidth)
|
|
490
|
+
&& Number.isFinite(underlayStrokeWidth)
|
|
491
|
+
&& underlayStrokeWidth >= routeStrokeWidth + 3;
|
|
492
|
+
const line = name === 'line';
|
|
493
|
+
const parsed = line ? null : inspectedPath(attrs, tag.index, pathIndex, 'relationship');
|
|
494
|
+
const segments = line ? lineSegments(attrs) : parsed.subpaths.flatMap(part => part.segments);
|
|
495
|
+
const borderSegments = line ? segments : parsed.subpaths.flatMap(part => part.straightSegments);
|
|
496
|
+
let routeSubpaths = line ? continuousRoutePoints(segments)
|
|
497
|
+
: parsed.subpaths.flatMap(part => continuousRoutePoints(part.straightSegments));
|
|
498
|
+
// Legacy single-path metadata remains supported. It cannot bridge actual moveto boundaries.
|
|
499
|
+
if (!useActualPoints && (line || parsed.subpaths.length === 1)) {
|
|
500
|
+
const authoredPoints = parseRoutePoints(attrs['data-composition-points']);
|
|
501
|
+
if (authoredPoints) routeSubpaths = [authoredPoints];
|
|
502
|
+
}
|
|
294
503
|
arrows.push({
|
|
295
|
-
kind:
|
|
504
|
+
kind: name,
|
|
296
505
|
index: index += 1,
|
|
297
506
|
raw,
|
|
507
|
+
// Trust route intent only for a semantic edge with one visible direct
|
|
508
|
+
// segment. A stale marker on bent/curved geometry cannot waive the gate.
|
|
509
|
+
authoredStraight: attrs['data-composition-route'] === 'straight'
|
|
510
|
+
&& Boolean(attrs['data-edge-from'] && attrs['data-edge-to'])
|
|
511
|
+
&& segments.length === 1 && borderSegments.length === 1
|
|
512
|
+
&& (name === 'line' || /^\s*M\s+[-+\d.eE]+\s+[-+\d.eE]+\s+L\s+[-+\d.eE]+\s+[-+\d.eE]+\s*$/.test(attrs.d || '')),
|
|
513
|
+
crossoverHalo: verifiedCrossoverHalo,
|
|
514
|
+
independentPorts: verifiedCrossoverHalo && attrs['data-composition-independent'] === 'true',
|
|
515
|
+
// Compatibility with first-round exports lacking a root layout contract.
|
|
516
|
+
// Readable-v2's root contract supersedes this narrower automatic-pair rule.
|
|
517
|
+
// This marker never certifies a crossover halo or waives a quality rule.
|
|
518
|
+
automaticWorkflowRoute: attrs['data-composition-routing'] === 'workflow-v2-auto',
|
|
519
|
+
junction: attrs['data-composition-junction'] || null,
|
|
520
|
+
width: routeStrokeWidth,
|
|
521
|
+
variant: raw.match(/\ba-(default|emphasis|security|dashed)\b/)?.[1] || 'default',
|
|
522
|
+
role: attrs['data-edge-role'],
|
|
298
523
|
segments,
|
|
299
524
|
borderSegments,
|
|
300
|
-
routePoints:
|
|
301
|
-
|
|
302
|
-
),
|
|
525
|
+
routePoints: routeSubpaths.length === 1 ? routeSubpaths[0] : [],
|
|
526
|
+
routeSubpaths,
|
|
303
527
|
from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
|
|
304
528
|
to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
|
|
305
529
|
id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
|
|
@@ -312,6 +536,138 @@ function collectArrows(fragment) {
|
|
|
312
536
|
return arrows;
|
|
313
537
|
}
|
|
314
538
|
|
|
539
|
+
function continuousRoutePoints(segments) {
|
|
540
|
+
const parts = [];
|
|
541
|
+
for (const { start, end } of segments) {
|
|
542
|
+
const previous = parts.at(-1);
|
|
543
|
+
// 非共线 Q 不参与直线预算;其两端也不能被拼接成一条虚构直线。
|
|
544
|
+
if (previous && previous.at(-1)[0] === start[0] && previous.at(-1)[1] === start[1]) {
|
|
545
|
+
previous.push(end);
|
|
546
|
+
} else {
|
|
547
|
+
parts.push([start, end]);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// 保留没有直线的子路径位置,避免把前一片段误认作语义终点。
|
|
551
|
+
return parts.length ? parts : [[]];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function inspectedPath(attrs, offset, pathIndex, role) {
|
|
555
|
+
if (pathAnalyses.has(offset)) return pathAnalyses.get(offset);
|
|
556
|
+
const result = analyzeSvgPath(decodeNumericReferences(attrs.d || ''));
|
|
557
|
+
if (result.ok) {
|
|
558
|
+
pathAnalyses.set(offset, result);
|
|
559
|
+
return result;
|
|
560
|
+
}
|
|
561
|
+
const { code, ...evidence } = result.error;
|
|
562
|
+
const id = attrs.id || attrs['data-edge-id'] || attrs['data-composition-frame-id'];
|
|
563
|
+
pathDiagnostics.push({
|
|
564
|
+
code,
|
|
565
|
+
severity: 'error',
|
|
566
|
+
message: `${role} path ${pathIndex}${id ? ` (${id})` : ''}: ${evidence.reason} at d offset ${evidence.tokenOffset}.`,
|
|
567
|
+
subject: { role, pathIndex, ...(id ? { id } : {}) },
|
|
568
|
+
evidence,
|
|
569
|
+
supportedFixes: [code === 'artifact/svg-path-unsupported'
|
|
570
|
+
? 'regenerate the inspected relationship or frame using M/L/H/V/Q/Z commands'
|
|
571
|
+
: 'correct the path data at the reported offset or regenerate it from the original diagram'],
|
|
572
|
+
});
|
|
573
|
+
const invalid = { ok: false, subpaths: [] };
|
|
574
|
+
pathAnalyses.set(offset, invalid);
|
|
575
|
+
return invalid;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Keep relationship identity separate from its disconnected drawing pieces.
|
|
579
|
+
// Normalizing each piece also makes evidence segment indices stable across consumers.
|
|
580
|
+
function routeParts(entry) {
|
|
581
|
+
const subpaths = entry.relation.routeSubpaths || [entry.points];
|
|
582
|
+
let segmentOffset = 0;
|
|
583
|
+
return subpaths.flatMap((points, subpathIndex) => {
|
|
584
|
+
const normalized = normalizeRoutePoints(points);
|
|
585
|
+
const part = { ...entry, points: normalized, segmentOffset,
|
|
586
|
+
sourceEndpoint: subpathIndex === 0, targetEndpoint: subpathIndex === subpaths.length - 1 };
|
|
587
|
+
segmentOffset += Math.max(0, normalized.length - 1);
|
|
588
|
+
return normalized.length > 1 ? [part] : [];
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function subpathBudgetMetrics(entries) {
|
|
593
|
+
if (entries.every(entry => entry.relation.routeSubpaths.length <= 1)) {
|
|
594
|
+
return routeBudgetMetrics({ routedRelations: entries });
|
|
595
|
+
}
|
|
596
|
+
const total = routeBudgetMetrics({ routedRelations: [] });
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
const parts = routeParts(entry);
|
|
599
|
+
const metrics = routeBudgetMetrics({ routedRelations: parts });
|
|
600
|
+
metrics.maxBends = parts.reduce((sum, part) => sum + Math.max(0, part.points.length - 2), 0);
|
|
601
|
+
// A relationship may exceed a budget in several pieces; count its identity once.
|
|
602
|
+
metrics.routesOverSuggestedBends = Number(metrics.maxBends > 2);
|
|
603
|
+
metrics.routesOverSuggestedStretch = Number(metrics.routesOverSuggestedStretch > 0);
|
|
604
|
+
for (const [key, value] of Object.entries(metrics)) {
|
|
605
|
+
if (key.startsWith('max')) total[key] = value == null ? total[key] : Math.max(total[key] ?? value, value);
|
|
606
|
+
else if (key.startsWith('min')) total[key] = value == null ? total[key] : Math.min(total[key] ?? value, value);
|
|
607
|
+
else total[key] += value;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return total;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function subpathRhythmIssues(entries) {
|
|
614
|
+
return entries.flatMap(entry => routeParts(entry).flatMap(part => (
|
|
615
|
+
collectRouteRhythmIssues({ routedRelations: [part] }).map(issue => ({
|
|
616
|
+
...issue, segmentIndex: issue.segmentIndex + part.segmentOffset,
|
|
617
|
+
}))
|
|
618
|
+
)));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function subpathCorridors({ routedRelations, ...options }) {
|
|
622
|
+
if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
|
|
623
|
+
return collectAmbiguousCorridors({ routedRelations, ...options });
|
|
624
|
+
}
|
|
625
|
+
const hits = [];
|
|
626
|
+
const parts = routedRelations.map(routeParts);
|
|
627
|
+
for (let left = 0; left < parts.length; left += 1) {
|
|
628
|
+
for (let right = left + 1; right < parts.length; right += 1) {
|
|
629
|
+
let longest;
|
|
630
|
+
for (const a of parts[left]) for (const b of parts[right]) {
|
|
631
|
+
const hit = collectAmbiguousCorridors({ routedRelations: [a, b], ...options })[0];
|
|
632
|
+
if (hit && (!longest || hit.overlapLength > longest.overlapLength + 0.0001)) {
|
|
633
|
+
longest = { ...hit, leftSegment: hit.leftSegment + a.segmentOffset,
|
|
634
|
+
rightSegment: hit.rightSegment + b.segmentOffset };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (longest) hits.push(longest);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return hits;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function subpathLabelClearance({ routedRelations, labels, threshold }) {
|
|
644
|
+
if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
|
|
645
|
+
return collectLabelRouteClearance({ routedRelations, labels, threshold });
|
|
646
|
+
}
|
|
647
|
+
// The shared helper deduplicates by relationship identity. Measure each piece
|
|
648
|
+
// independently, then preserve one nearest hit per label/relationship pair.
|
|
649
|
+
const hits = [];
|
|
650
|
+
const seen = new Set();
|
|
651
|
+
for (const entry of routedRelations) {
|
|
652
|
+
const relation = entry.relation;
|
|
653
|
+
const identity = relation.key !== undefined ? `key:${relation.key}`
|
|
654
|
+
: relation.id ? `id:${relation.from || ''}\0${relation.to || ''}\0${relation.id}` : `index:${entry.relationIndex}`;
|
|
655
|
+
if (seen.has(identity) || !routeParts(entry).length) continue;
|
|
656
|
+
seen.add(identity);
|
|
657
|
+
const nearest = new Map();
|
|
658
|
+
for (const part of routeParts(entry)) {
|
|
659
|
+
for (const hit of collectLabelRouteClearance({ routedRelations: [part], labels, threshold })) {
|
|
660
|
+
const current = nearest.get(hit.labelRelationIndex);
|
|
661
|
+
if (!current || hit.clearance < current.clearance) nearest.set(hit.labelRelationIndex, {
|
|
662
|
+
...hit, segmentIndex: hit.segmentIndex + part.segmentOffset,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
hits.push(...nearest.values());
|
|
667
|
+
}
|
|
668
|
+
return hits;
|
|
669
|
+
}
|
|
670
|
+
|
|
315
671
|
function collectRelationshipLabelMasks(fragment, arrows) {
|
|
316
672
|
const labels = [];
|
|
317
673
|
for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
|
|
@@ -378,14 +734,24 @@ function parseRoutePoints(value) {
|
|
|
378
734
|
return points.length >= 2 && points.every(isPoint) ? points : null;
|
|
379
735
|
}
|
|
380
736
|
|
|
381
|
-
function collectRelationshipCrossings(arrows) {
|
|
382
|
-
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length)
|
|
737
|
+
function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
|
|
738
|
+
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length).map(arrow => ({
|
|
739
|
+
...arrow,
|
|
740
|
+
// Readable-v2 routePoints come from the visible path, never its metadata.
|
|
741
|
+
// A straight-through via is not a visual endpoint; preserve real bends.
|
|
742
|
+
segments: includeSharedEndpoints ? arrow.routeSubpaths.flatMap(forwardCollinearAnalysisSegments) : arrow.segments,
|
|
743
|
+
}));
|
|
383
744
|
const crossings = [];
|
|
384
745
|
for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
|
|
385
746
|
const left = relationships[leftIndex];
|
|
386
747
|
for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
|
|
387
748
|
const right = relationships[rightIndex];
|
|
388
|
-
|
|
749
|
+
// A shared semantic endpoint does not make an interior X a junction
|
|
750
|
+
// when both paths opt into automatic workflow or independent-port checks.
|
|
751
|
+
if ([left.from, left.to].some((id) => id === right.from || id === right.to)
|
|
752
|
+
&& !includeSharedEndpoints
|
|
753
|
+
&& !(left.independentPorts && right.independentPorts)
|
|
754
|
+
&& !(left.automaticWorkflowRoute && right.automaticWorkflowRoute)) continue;
|
|
389
755
|
let point = null;
|
|
390
756
|
for (const leftSegment of left.segments) {
|
|
391
757
|
for (const rightSegment of right.segments) {
|
|
@@ -406,6 +772,81 @@ function relationshipName(arrow) {
|
|
|
406
772
|
: `relationship "${arrow.from}" -> "${arrow.to}"`;
|
|
407
773
|
}
|
|
408
774
|
|
|
775
|
+
// Review evidence only. Mask rectangles are the visible node bounds emitted by
|
|
776
|
+
// our renderers. Skip transformed ancestry rather than mixing coordinate spaces.
|
|
777
|
+
function collectUntransformedNodeRects(fragment) {
|
|
778
|
+
const groups = [];
|
|
779
|
+
const nodes = new Map();
|
|
780
|
+
for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
781
|
+
if (!token[2]) continue;
|
|
782
|
+
const name = token[2].toLowerCase();
|
|
783
|
+
if (name === 'g') {
|
|
784
|
+
if (token[1]) groups.pop();
|
|
785
|
+
else if (!/\/\s*>$/.test(token[0])) groups.push(parseAttrs(token[0]));
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
if (name !== 'rect' || token[1] || groups.some((group) => group.transform)) continue;
|
|
789
|
+
const attrs = parseAttrs(token[0]);
|
|
790
|
+
const owner = [...groups].reverse().find((group) => group['data-node-id']);
|
|
791
|
+
if (!owner || attrs.transform || !String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
|
|
792
|
+
const box = ['x', 'y', 'width', 'height'].map((key) => numberAttr(attrs, key));
|
|
793
|
+
if (!box.every(Number.isFinite) || box[2] <= 0 || box[3] <= 0) continue;
|
|
794
|
+
const id = owner['data-node-id'];
|
|
795
|
+
// Ambiguous ownership is not reliable evidence for moving a node.
|
|
796
|
+
nodes.set(id, nodes.has(id) ? null : { id, label: owner['data-node-label'] || id, box });
|
|
797
|
+
}
|
|
798
|
+
return [...nodes.values()].filter(Boolean);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function directCorridorBlockers(relation, nodes) {
|
|
802
|
+
const from = nodes.find((node) => node.id === relation.from);
|
|
803
|
+
const to = nodes.find((node) => node.id === relation.to);
|
|
804
|
+
if (!from || !to || from === to) return [];
|
|
805
|
+
const center = ({ box: [x, y, w, h] }) => [x + w / 2, y + h / 2];
|
|
806
|
+
const a = center(from);
|
|
807
|
+
const b = center(to);
|
|
808
|
+
const horizontal = Math.abs(a[1] - b[1]) < 0.01;
|
|
809
|
+
const vertical = Math.abs(a[0] - b[0]) < 0.01;
|
|
810
|
+
if (horizontal === vertical) return [];
|
|
811
|
+
const axis = horizontal ? 0 : 1;
|
|
812
|
+
const cross = 1 - axis;
|
|
813
|
+
const [first, last] = a[axis] < b[axis] ? [from, to] : [to, from];
|
|
814
|
+
const low = first.box[axis] + first.box[axis + 2];
|
|
815
|
+
const high = last.box[axis];
|
|
816
|
+
if (high <= low) return [];
|
|
817
|
+
return nodes.filter((node) => node !== from && node !== to
|
|
818
|
+
&& node.box[axis] < high && node.box[axis] + node.box[axis + 2] > low
|
|
819
|
+
&& node.box[cross] < a[cross] && node.box[cross] + node.box[cross + 2] > a[cross]);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Automatic ports need a 16px corner gutter and 14px between neighbours, so a
|
|
823
|
+
// side facing more counterparts than that fits pushes routes onto other sides.
|
|
824
|
+
function crowdedNodeSides(arrows, nodes) {
|
|
825
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
826
|
+
const demand = new Map();
|
|
827
|
+
for (const arrow of arrows) {
|
|
828
|
+
const from = byId.get(arrow.from);
|
|
829
|
+
const to = byId.get(arrow.to);
|
|
830
|
+
if (!from || !to || from === to) continue;
|
|
831
|
+
for (const [node, other] of [[from, to], [to, from]]) {
|
|
832
|
+
const [x, y, w, h] = node.box;
|
|
833
|
+
const dx = other.box[0] + other.box[2] / 2 - (x + w / 2);
|
|
834
|
+
const dy = other.box[1] + other.box[3] / 2 - (y + h / 2);
|
|
835
|
+
const side = dx !== 0 && Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy > 0 ? 'bottom' : 'top');
|
|
836
|
+
const key = `${node.id}\u0000${side}`;
|
|
837
|
+
const entry = demand.get(key) || { node, side, relationships: 0 };
|
|
838
|
+
entry.relationships += 1;
|
|
839
|
+
demand.set(key, entry);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return [...demand.values()].flatMap(({ node, side, relationships }) => {
|
|
843
|
+
const sidePx = side === 'left' || side === 'right' ? node.box[3] : node.box[2];
|
|
844
|
+
const neededPx = 32 + 14 * (relationships - 1);
|
|
845
|
+
return relationships > 1 && sidePx < neededPx
|
|
846
|
+
? [{ node: node.id, label: node.label, side, relationships, sidePx, neededPx }] : [];
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
409
850
|
function relationshipRecord(arrow) {
|
|
410
851
|
const stableIndex = Number(arrow.key);
|
|
411
852
|
return {
|
|
@@ -420,12 +861,16 @@ function relationshipRecord(arrow) {
|
|
|
420
861
|
|
|
421
862
|
function collectCompositionFrames(fragment) {
|
|
422
863
|
const frames = [];
|
|
423
|
-
|
|
864
|
+
let pathIndex = 0;
|
|
865
|
+
for (const match of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
866
|
+
const name = match[2]?.toLowerCase();
|
|
867
|
+
if (match[1] || !['rect', 'path', 'line'].includes(name)) continue;
|
|
868
|
+
if (name === 'path') pathIndex += 1;
|
|
424
869
|
const attrs = parseAttrs(match[0]);
|
|
425
870
|
const kind = attrs['data-composition-frame-kind'];
|
|
426
871
|
if (!kind) continue;
|
|
427
872
|
const identity = attrs['data-composition-frame-id'] || frames.length;
|
|
428
|
-
if (
|
|
873
|
+
if (name === 'rect') {
|
|
429
874
|
const frame = {
|
|
430
875
|
kind,
|
|
431
876
|
id: identity,
|
|
@@ -438,9 +883,9 @@ function collectCompositionFrames(fragment) {
|
|
|
438
883
|
if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
|
|
439
884
|
continue;
|
|
440
885
|
}
|
|
441
|
-
const segments =
|
|
886
|
+
const segments = name === 'line'
|
|
442
887
|
? lineSegments(attrs)
|
|
443
|
-
:
|
|
888
|
+
: inspectedPath(attrs, match.index, pathIndex, 'structural-frame').subpaths.flatMap(part => part.segments);
|
|
444
889
|
for (const [segmentIndex, segment] of segments.entries()) {
|
|
445
890
|
frames.push({
|
|
446
891
|
kind,
|
|
@@ -454,6 +899,207 @@ function collectCompositionFrames(fragment) {
|
|
|
454
899
|
return frames;
|
|
455
900
|
}
|
|
456
901
|
|
|
902
|
+
// Evidence for author review only. Automatic Architecture is identifiable by
|
|
903
|
+
// its intrinsic Reader contract; authored canvases must retain their geometry.
|
|
904
|
+
function collectArchitectureLeadingSpace({ svgAttrs, fragment, nodeRects, frames, arrows, labels }) {
|
|
905
|
+
const evidence = { measured: false, reviewSuggested: false };
|
|
906
|
+
if (svgAttrs['data-reader-fit'] !== 'intrinsic-height'
|
|
907
|
+
|| svgAttrs['data-reader-primary-text'] !== '14'
|
|
908
|
+
|| svgAttrs.transform
|
|
909
|
+
|| [...fragment.matchAll(/<g\b[^>]*\btransform\s*=[^>]*>/gi)]
|
|
910
|
+
.some((match) => !/\bdata-semantic-sigil=/.test(match[0]))
|
|
911
|
+
|| /<(?:path|line|rect|text)\b[^>]*\btransform\s*=/i.test(fragment)) return evidence;
|
|
912
|
+
const [originX, originY, width, height] = viewBoxRect(svgAttrs);
|
|
913
|
+
if (![originX, originY, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
|
|
914
|
+
const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
|
|
915
|
+
if (!nodeRects.length || nodeRects.length !== nodeCount) return evidence;
|
|
916
|
+
const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
|
|
917
|
+
if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
|
|
918
|
+
|
|
919
|
+
const occupied = nodeRects.map((node) => node.box[1]);
|
|
920
|
+
for (const frame of frames) {
|
|
921
|
+
const top = frame.shape === 'line'
|
|
922
|
+
? Math.min(frame.start[1], frame.end[1]) : frame.y;
|
|
923
|
+
if (!Number.isFinite(top)) return evidence;
|
|
924
|
+
occupied.push(top);
|
|
925
|
+
}
|
|
926
|
+
// Boundary titles can protrude above their structural frame.
|
|
927
|
+
for (const match of fragment.matchAll(/<g\b[^>]*\bdata-graph-role="structural-frame-label"[^>]*>[\s\S]*?<rect\b[^>]*\bdata-graph-role="structural-frame-label-mask"[^>]*>/gi)) {
|
|
928
|
+
const rect = parseAttrs(match[0].match(/<rect\b[^>]*>/i)?.[0] || '');
|
|
929
|
+
const y = numberAttr(rect, 'y');
|
|
930
|
+
if (!Number.isFinite(y)) return evidence;
|
|
931
|
+
occupied.push(y);
|
|
932
|
+
}
|
|
933
|
+
for (const arrow of semanticArrows) {
|
|
934
|
+
for (const points of arrow.routeSubpaths) for (const point of points) occupied.push(point[1]);
|
|
935
|
+
}
|
|
936
|
+
for (const label of labels) occupied.push(label.rect.y);
|
|
937
|
+
if (!occupied.every(Number.isFinite)) return evidence;
|
|
938
|
+
const occupiedTop = Math.min(...occupied);
|
|
939
|
+
const gap = Math.max(0, occupiedTop - originY);
|
|
940
|
+
const heights = nodeRects.map((node) => node.box[3]).sort((a, b) => a - b);
|
|
941
|
+
const typicalNodeHeight = heights[Math.floor(heights.length / 2)];
|
|
942
|
+
const ratio = gap / height;
|
|
943
|
+
return {
|
|
944
|
+
measured: true,
|
|
945
|
+
emptyTopPx: Math.round(gap * 10) / 10,
|
|
946
|
+
emptyTopRatio: Math.round(ratio * 1000) / 1000,
|
|
947
|
+
occupiedTop: Math.round(occupiedTop * 10) / 10,
|
|
948
|
+
viewBoxTop: originY,
|
|
949
|
+
canvasHeight: height,
|
|
950
|
+
typicalNodeHeight,
|
|
951
|
+
reviewSuggested: gap > 2 * typicalNodeHeight && ratio > 0.2,
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Advisory only: fixed columns are a compatibility contract. Measure semantic
|
|
956
|
+
// content, including long labels/notes, rather than treating every wide canvas
|
|
957
|
+
// as wasted space. Auto-sized segment frames do not add a participant column.
|
|
958
|
+
function collectSequenceColumnSpace({ svgAttrs, fragment, nodeRects, arrows }) {
|
|
959
|
+
const columnFit = svgAttrs['data-sequence-column-fit'];
|
|
960
|
+
if (!['fixed', 'spread'].includes(columnFit)) return null;
|
|
961
|
+
const evidence = { measured: false, reviewSuggested: false, columnFit };
|
|
962
|
+
if (svgAttrs.transform) return evidence;
|
|
963
|
+
// Brand badges stay inside their participant box. Ignore only that subtree's
|
|
964
|
+
// transforms, including the preset path or nested fallback icon's scale.
|
|
965
|
+
const brandGroups = [];
|
|
966
|
+
for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
967
|
+
if (!token[2]) continue;
|
|
968
|
+
const name = token[2].toLowerCase();
|
|
969
|
+
if (token[1]) {
|
|
970
|
+
if (name === 'g') brandGroups.pop();
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
const attrs = parseAttrs(token[0]);
|
|
974
|
+
const inBrand = brandGroups.at(-1) === true || (name === 'g'
|
|
975
|
+
&& Boolean(attrs['data-brand-mark'])
|
|
976
|
+
&& String(attrs.class || '').split(/\s+/).includes('brand-mark'));
|
|
977
|
+
if (!inBrand && attrs.transform && ['g', 'path', 'line', 'rect', 'text'].includes(name)
|
|
978
|
+
&& !(name === 'g' && attrs['data-semantic-sigil'])) return evidence;
|
|
979
|
+
if (name === 'g' && !/\/\s*>$/.test(token[0])) brandGroups.push(inBrand);
|
|
980
|
+
}
|
|
981
|
+
const [originX, , width, height] = viewBoxRect(svgAttrs);
|
|
982
|
+
if (![originX, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
|
|
983
|
+
const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
|
|
984
|
+
if (!nodeRects.length || nodeCount !== nodeRects.length) return evidence;
|
|
985
|
+
const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
|
|
986
|
+
if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
|
|
987
|
+
const rightEdges = nodeRects.map((node) => node.box[0] + node.box[2]);
|
|
988
|
+
for (const arrow of semanticArrows) {
|
|
989
|
+
for (const points of arrow.routeSubpaths) for (const point of points) rightEdges.push(point[0]);
|
|
990
|
+
}
|
|
991
|
+
// Label plates, activations and segment titles also reserve horizontal room.
|
|
992
|
+
for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
|
|
993
|
+
const attrs = parseAttrs(match[0]);
|
|
994
|
+
if (!String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
|
|
995
|
+
rightEdges.push(numberAttr(attrs, 'x') + numberAttr(attrs, 'width'));
|
|
996
|
+
}
|
|
997
|
+
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
998
|
+
const attrs = parseAttrs(match[1]);
|
|
999
|
+
// A wrapped note places each line in a <tspan> with its own x. Any other
|
|
1000
|
+
// line offset is not measured here.
|
|
1001
|
+
const lines = /<tspan\b/i.test(match[2])
|
|
1002
|
+
? [...match[2].matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)]
|
|
1003
|
+
.map((line) => ({ attrs: { ...attrs, ...parseAttrs(line[1]) }, text: stripTags(line[2]).trim() }))
|
|
1004
|
+
: [{ attrs, text: stripTags(match[2]).trim() }];
|
|
1005
|
+
if (!lines.length || lines.some((line) => line.attrs.dx !== undefined)) return evidence;
|
|
1006
|
+
for (const line of lines) {
|
|
1007
|
+
const box = textBox(line.attrs, line.text);
|
|
1008
|
+
if (!box) return evidence;
|
|
1009
|
+
rightEdges.push(box.x2);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (!rightEdges.every(Number.isFinite)) return evidence;
|
|
1013
|
+
const occupiedRight = Math.max(...rightEdges);
|
|
1014
|
+
const gap = Math.max(0, originX + width - occupiedRight);
|
|
1015
|
+
const ratio = gap / width;
|
|
1016
|
+
const widths = nodeRects.map((node) => node.box[2]).sort((a, b) => a - b);
|
|
1017
|
+
const typicalParticipantWidth = widths[Math.floor(widths.length / 2)];
|
|
1018
|
+
return {
|
|
1019
|
+
measured: true,
|
|
1020
|
+
columnFit,
|
|
1021
|
+
participantCount: nodeCount,
|
|
1022
|
+
occupiedRight: Math.round(occupiedRight * 10) / 10,
|
|
1023
|
+
viewBoxLeft: originX,
|
|
1024
|
+
canvasWidth: width,
|
|
1025
|
+
emptyRightPx: Math.round(gap * 10) / 10,
|
|
1026
|
+
emptyRightRatio: Math.round(ratio * 1000) / 1000,
|
|
1027
|
+
typicalParticipantWidth,
|
|
1028
|
+
// Avoid stretching a small conversation merely to fill its canvas. These
|
|
1029
|
+
// conservative review thresholds never contribute errors or warnings.
|
|
1030
|
+
reviewSuggested: columnFit === 'fixed' && nodeCount >= 4
|
|
1031
|
+
&& gap > 2 * typicalParticipantWidth && ratio > 0.25,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Sequence notes are direct text children of a semantic message group; message
|
|
1036
|
+
// labels use a nested group. Inspect the emitted lines without changing layout.
|
|
1037
|
+
// This is advisory: phase ownership still belongs to the author.
|
|
1038
|
+
function collectSequenceNoteBorders({ svgAttrs, fragment, frames, arrows }) {
|
|
1039
|
+
if (!svgAttrs['data-sequence-column-fit'] || svgAttrs.transform) return [];
|
|
1040
|
+
const segments = frames.filter(frame => frame.kind === 'segment' && !frame.shape);
|
|
1041
|
+
if (!segments.length) return [];
|
|
1042
|
+
const issues = [], stack = [];
|
|
1043
|
+
for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
1044
|
+
const name = tag[2]?.toLowerCase();
|
|
1045
|
+
if (name === 'g') {
|
|
1046
|
+
if (tag[1]) stack.pop();
|
|
1047
|
+
else if (!tag[0].endsWith('/>')) stack.push(parseAttrs(tag[0]));
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (name === 'rect' && !tag[1]) {
|
|
1051
|
+
const attrs = parseAttrs(tag[0]);
|
|
1052
|
+
if (attrs['data-composition-frame-kind'] === 'segment' && (attrs.transform || stack.some(group => group.transform))) return [];
|
|
1053
|
+
}
|
|
1054
|
+
if (name !== 'text' || tag[1] || stack.some(attrs => attrs.transform)) continue;
|
|
1055
|
+
const owner = stack.at(-1), attrs = parseAttrs(tag[0]);
|
|
1056
|
+
if (!owner?.['data-edge-key'] || !['context', 'fine'].includes(attrs['data-detail']) || attrs.transform || attrs.rotate || attrs.dx || attrs.dy) continue;
|
|
1057
|
+
const index = Number(owner['data-edge-key']);
|
|
1058
|
+
if (!Number.isSafeInteger(index) || index < 0) continue;
|
|
1059
|
+
const arrow = arrows[index], arrowY = arrow?.routePoints?.[0]?.[1];
|
|
1060
|
+
if (!Number.isFinite(arrowY)) continue;
|
|
1061
|
+
const end = fragment.indexOf('</text>', tag.index + tag[0].length);
|
|
1062
|
+
if (end < 0) continue;
|
|
1063
|
+
const content = fragment.slice(tag.index + tag[0].length, end);
|
|
1064
|
+
let baseline = numberAttr(attrs, 'y');
|
|
1065
|
+
const boxes = [];
|
|
1066
|
+
const spans = [...content.matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)];
|
|
1067
|
+
let supported = true;
|
|
1068
|
+
for (const span of spans.length ? spans : [[null, '', content]]) {
|
|
1069
|
+
const line = parseAttrs(span[1]);
|
|
1070
|
+
if (line.y !== undefined || line.dx !== undefined || line.rotate !== undefined || line.transform !== undefined) { supported = false; break; }
|
|
1071
|
+
baseline += Number(line.dy || 0);
|
|
1072
|
+
const box = textBox({ ...attrs, ...line, y: String(baseline) }, stripTags(span[2]));
|
|
1073
|
+
if (!box || ![box.x1, box.x2, box.y1, box.y2].every(Number.isFinite)) { supported = false; break; }
|
|
1074
|
+
boxes.push(box);
|
|
1075
|
+
}
|
|
1076
|
+
if (!supported || !boxes.length) continue;
|
|
1077
|
+
const note = { x: Math.min(...boxes.map(b => b.x1)), y: Math.min(...boxes.map(b => b.y1)) };
|
|
1078
|
+
note.width = Math.max(...boxes.map(b => b.x2)) - note.x;
|
|
1079
|
+
note.height = Math.max(...boxes.map(b => b.y2)) - note.y;
|
|
1080
|
+
const bottom = note.y + note.height;
|
|
1081
|
+
for (const frame of segments) {
|
|
1082
|
+
const border = frame.y + frame.height;
|
|
1083
|
+
if (!(arrowY >= frame.y && arrowY < border && note.y < border && bottom > border + 0.5)) continue;
|
|
1084
|
+
const requiredTo = Math.ceil(bottom + 4);
|
|
1085
|
+
// A single-field fix may not swallow another message or stage, or push
|
|
1086
|
+
// the frame beyond an authored canvas. Otherwise ask for a joint review.
|
|
1087
|
+
const safe = /^\d+$/.test(String(frame.id)) && requiredTo <= viewBoxRect(svgAttrs)[1] + viewBoxRect(svgAttrs)[3] - 45
|
|
1088
|
+
&& !arrows.some(other => other.routePoints.some(point => point[1] > border - 4 && point[1] < requiredTo + 4))
|
|
1089
|
+
&& !segments.some(other => other !== frame && other.y < requiredTo && other.y + other.height > border);
|
|
1090
|
+
const segmentPath = `/segments/${frame.id}/to`;
|
|
1091
|
+
issues.push({
|
|
1092
|
+
code: 'sequence/note-segment-border', reviewSuggested: true,
|
|
1093
|
+
message: `Note on message "${owner['data-edge-label'] || owner['data-edge-id'] || index}" ends at y=${Math.ceil(bottom)}, crossing its segment bottom y=${border}. ${safe ? `Set ${segmentPath} to ${requiredTo} to keep the full note inside.` : 'Review the note and neighbouring phase boundaries together; keep the full note and message order.'}`,
|
|
1094
|
+
subject: { diagramType: 'sequence', path: `/messages/${index}/note` },
|
|
1095
|
+
evidence: { note, lines: boxes.length, messageY: arrowY, segment: { path: segmentPath, from: frame.y, to: border }, requiredTo },
|
|
1096
|
+
supportedFixes: safe ? [`set ${segmentPath} to ${requiredTo}`] : [],
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return issues;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
457
1103
|
function frameName(frame) {
|
|
458
1104
|
return `${frame.kind || 'frame'} "${frame.id}"`;
|
|
459
1105
|
}
|
|
@@ -473,89 +1119,8 @@ function lineSegments(attrs) {
|
|
|
473
1119
|
return [{ start, end }];
|
|
474
1120
|
}
|
|
475
1121
|
|
|
476
|
-
function pathSegments(d) {
|
|
477
|
-
const points = pointsFromPath(d);
|
|
478
|
-
const segments = [];
|
|
479
|
-
for (let i = 1; i < points.length; i += 1) {
|
|
480
|
-
segments.push({ start: points[i - 1], end: points[i] });
|
|
481
|
-
}
|
|
482
|
-
return segments;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Border runs use exact visible primitives. Non-collinear Q curves are never
|
|
486
|
-
// flattened into chords here: a tangent or sampled near-horizontal curve is
|
|
487
|
-
// not a structural border run. A fully collinear Q remains a straight visible
|
|
488
|
-
// primitive and is included.
|
|
489
|
-
function straightPathSegments(d) {
|
|
490
|
-
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
|
491
|
-
const segments = [];
|
|
492
|
-
let i = 0;
|
|
493
|
-
let command = '';
|
|
494
|
-
let current = [0, 0];
|
|
495
|
-
let start = null;
|
|
496
|
-
while (i < tokens.length) {
|
|
497
|
-
if (isCommand(tokens[i])) command = tokens[i++];
|
|
498
|
-
if (!command) break;
|
|
499
|
-
const absolute = command === command.toUpperCase();
|
|
500
|
-
switch (command.toUpperCase()) {
|
|
501
|
-
case 'M':
|
|
502
|
-
case 'L': {
|
|
503
|
-
let first = true;
|
|
504
|
-
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
|
505
|
-
const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
|
|
506
|
-
if (!point.every(Number.isFinite)) break;
|
|
507
|
-
const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
|
|
508
|
-
if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
|
|
509
|
-
current = next;
|
|
510
|
-
if (!start) start = current;
|
|
511
|
-
first = false;
|
|
512
|
-
}
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
case 'H': {
|
|
516
|
-
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
517
|
-
const value = Number.parseFloat(tokens[i++]);
|
|
518
|
-
if (!Number.isFinite(value)) break;
|
|
519
|
-
const next = [absolute ? value : current[0] + value, current[1]];
|
|
520
|
-
segments.push({ start: current, end: next });
|
|
521
|
-
current = next;
|
|
522
|
-
}
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
case 'V': {
|
|
526
|
-
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
527
|
-
const value = Number.parseFloat(tokens[i++]);
|
|
528
|
-
if (!Number.isFinite(value)) break;
|
|
529
|
-
const next = [current[0], absolute ? value : current[1] + value];
|
|
530
|
-
segments.push({ start: current, end: next });
|
|
531
|
-
current = next;
|
|
532
|
-
}
|
|
533
|
-
break;
|
|
534
|
-
}
|
|
535
|
-
case 'Q': {
|
|
536
|
-
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
|
537
|
-
const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
|
|
538
|
-
if (!values.every(Number.isFinite)) break;
|
|
539
|
-
const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
|
|
540
|
-
const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
|
|
541
|
-
if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
|
|
542
|
-
current = end;
|
|
543
|
-
}
|
|
544
|
-
break;
|
|
545
|
-
}
|
|
546
|
-
case 'Z':
|
|
547
|
-
if (start) segments.push({ start: current, end: start });
|
|
548
|
-
current = start || current;
|
|
549
|
-
command = '';
|
|
550
|
-
break;
|
|
551
|
-
default:
|
|
552
|
-
return [];
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
|
|
556
|
-
}
|
|
557
|
-
|
|
558
1122
|
function diagonalStraightSegments(arrow) {
|
|
1123
|
+
if (arrow.authoredStraight) return [];
|
|
559
1124
|
return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
|
|
560
1125
|
Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
|
561
1126
|
? [{ segmentIndex, start, end }]
|
|
@@ -579,7 +1144,7 @@ function collectLegendBoxes(fragment) {
|
|
|
579
1144
|
|
|
580
1145
|
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
581
1146
|
const attrs = parseAttrs(match[1]);
|
|
582
|
-
const box = textBox(attrs, stripTags(match[2]).trim());
|
|
1147
|
+
const box = textBox(attrs, stripTags(match[2]).trim(), legendTextWidth);
|
|
583
1148
|
if (box) boxes.push(box);
|
|
584
1149
|
}
|
|
585
1150
|
|
|
@@ -600,12 +1165,20 @@ function collectLegendCollisions(arrows, boxes) {
|
|
|
600
1165
|
return collisions;
|
|
601
1166
|
}
|
|
602
1167
|
|
|
603
|
-
|
|
1168
|
+
// The footprint a legend entry reserves is the renderer's own: textUnits() at
|
|
1169
|
+
// TEXT_ADVANCE_EM (renderers/shared/legend.mjs). The default estimate below stays
|
|
1170
|
+
// as it is for the readability projection, which is deliberately conservative.
|
|
1171
|
+
function legendTextWidth(text, fontSize) {
|
|
1172
|
+
const TEXT_ADVANCE_EM = 0.62;
|
|
1173
|
+
return Math.max(fontSize, textUnits(text) * fontSize * TEXT_ADVANCE_EM);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function textBox(attrs, text, measure = estimatedTextWidth) {
|
|
604
1177
|
const x = numberAttr(attrs, 'x');
|
|
605
1178
|
const y = numberAttr(attrs, 'y');
|
|
606
1179
|
const fontSize = Number.parseFloat(attrs['font-size'] || '10');
|
|
607
1180
|
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
|
|
608
|
-
const width =
|
|
1181
|
+
const width = measure(text, fontSize);
|
|
609
1182
|
const anchor = attrs['text-anchor'] || 'start';
|
|
610
1183
|
let x1 = x;
|
|
611
1184
|
if (anchor === 'middle') x1 = x - width / 2;
|
|
@@ -619,35 +1192,128 @@ function textBox(attrs, text) {
|
|
|
619
1192
|
};
|
|
620
1193
|
}
|
|
621
1194
|
|
|
622
|
-
|
|
1195
|
+
// A foreign artifact may author a legal non-zero viewBox origin, so containment
|
|
1196
|
+
// needs all four numbers; sizing checks read the trailing pair.
|
|
1197
|
+
function viewBoxRect(svgAttrs) {
|
|
623
1198
|
const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
1199
|
+
return viewBox.length === 4 ? viewBox : [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function viewBoxSize(svgAttrs) {
|
|
1203
|
+
return viewBoxRect(svgAttrs).slice(2);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function hasAttribute(attrs, name) {
|
|
1207
|
+
return new RegExp('(?:^|\\s)' + name + '(?:\\s*=|\\s|$)', 'i').test(attrs);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function readerContractFromHtml(source) {
|
|
1211
|
+
const markers = [...source.matchAll(/<meta\b[^>]*>/gi)]
|
|
1212
|
+
.map((match) => parseAttrs(match[0]))
|
|
1213
|
+
.filter((attrs) => attrs.name === 'archify-reader-contract');
|
|
1214
|
+
return markers.length === 1 && markers[0].content === DECLARED_WIDE_READER_CONTRACT
|
|
1215
|
+
? DECLARED_WIDE_READER_CONTRACT
|
|
1216
|
+
: null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function collectDesktopReadability(svgAttrs, fragment, contract) {
|
|
1220
|
+
const [viewBoxWidth, viewBoxHeight] = viewBoxSize(svgAttrs);
|
|
1221
|
+
const requestedMinimumTextPx = Number.parseFloat(svgAttrs['data-reader-min-text'] || '');
|
|
1222
|
+
const entries = [];
|
|
1223
|
+
let invalidSemanticText = false;
|
|
1224
|
+
const groups = [];
|
|
1225
|
+
// Keep the complete ancestry, rather than a node-only stack: an edge can
|
|
1226
|
+
// share its context group with its label or nest that group (Sequence).
|
|
1227
|
+
for (const match of fragment.matchAll(/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<text\b([^>]*)>([\s\S]*?)<\/text>|<g\b[^>]*>|<\/g\s*>/gi)) {
|
|
1228
|
+
if (match[0].startsWith('<!')) continue;
|
|
1229
|
+
if (match[1] === undefined) {
|
|
1230
|
+
if (/^<\/g/i.test(match[0])) groups.pop();
|
|
1231
|
+
else if (!/\/\s*>$/.test(match[0])) groups.push(parseAttrs(match[0]));
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
633
1234
|
const attrs = parseAttrs(match[1]);
|
|
1235
|
+
if (attrs['data-detail'] === 'fine' || groups.some((group) => group['data-detail'] === 'fine')) continue;
|
|
1236
|
+
const primary = hasAttribute(match[1], 'data-node-label');
|
|
1237
|
+
const boundary = hasAttribute(match[1], 'data-boundary-label');
|
|
1238
|
+
const context = attrs['data-detail'] === 'context'
|
|
1239
|
+
|| groups.some((group) => group['data-detail'] === 'context');
|
|
1240
|
+
const nodeOwner = [...groups].reverse().find((group) => group['data-node-id']);
|
|
1241
|
+
const edgeOwner = [...groups].reverse().find((group) => group['data-edge-from'] && group['data-edge-to']);
|
|
1242
|
+
const owner = primary
|
|
1243
|
+
? { kind: 'node', id: nodeOwner?.['data-node-id'] || null }
|
|
1244
|
+
: boundary ? { kind: 'boundary', id: null }
|
|
1245
|
+
: context && edgeOwner ? {
|
|
1246
|
+
kind: 'edge', id: edgeOwner['data-edge-id'] || null,
|
|
1247
|
+
from: edgeOwner['data-edge-from'], to: edgeOwner['data-edge-to'],
|
|
1248
|
+
}
|
|
1249
|
+
: context && nodeOwner ? { kind: 'node', id: nodeOwner['data-node-id'] }
|
|
1250
|
+
: null;
|
|
1251
|
+
// A context text with neither semantic owner is legend/fine/loose copy.
|
|
1252
|
+
if (!owner) continue;
|
|
634
1253
|
const fontSize = Number.parseFloat(attrs['font-size'] || '');
|
|
635
|
-
if (!Number.isFinite(fontSize))
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
1254
|
+
if (!Number.isFinite(fontSize)) {
|
|
1255
|
+
invalidSemanticText = true;
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
if (fontSize <= 0) invalidSemanticText = true;
|
|
1259
|
+
entries.push({
|
|
1260
|
+
...(owner.kind === 'node' && owner.id ? { nodeId: owner.id } : {}),
|
|
1261
|
+
owner,
|
|
641
1262
|
text: stripTags(match[2]).trim(),
|
|
642
|
-
detail: primary
|
|
643
|
-
? 'primary'
|
|
644
|
-
: boundary ? 'boundary' : 'context',
|
|
1263
|
+
detail: primary ? 'primary' : boundary ? 'boundary' : owner.kind === 'edge' ? 'edge' : 'context',
|
|
645
1264
|
sourceFontPx: fontSize,
|
|
646
|
-
|
|
647
|
-
};
|
|
648
|
-
if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
|
|
1265
|
+
});
|
|
649
1266
|
}
|
|
650
|
-
|
|
1267
|
+
const minimumSourceTextPx = entries.length ? Math.min(...entries.map((entry) => entry.sourceFontPx)) : Number.NaN;
|
|
1268
|
+
const eligible = contract === DECLARED_WIDE_READER_CONTRACT
|
|
1269
|
+
&& ['intrinsic-height', 'width-first'].includes(svgAttrs['data-reader-fit'])
|
|
1270
|
+
&& Number.isFinite(requestedMinimumTextPx) && requestedMinimumTextPx > 0
|
|
1271
|
+
&& !invalidSemanticText && entries.length > 0;
|
|
1272
|
+
const declared = eligible ? declaredWideReadabilityBudget({
|
|
1273
|
+
viewBoxWidth,
|
|
1274
|
+
viewBoxHeight,
|
|
1275
|
+
minimumSourceTextPx,
|
|
1276
|
+
requestedMinimumTextPx,
|
|
1277
|
+
}) : null;
|
|
1278
|
+
const availableDiagramWidth = declared?.guaranteedSvgWidth ?? DESKTOP_READER_DIAGRAM_WIDTH;
|
|
1279
|
+
const budgetBasis = declared ? 'recognized-declared-wide' : 'legacy-930';
|
|
1280
|
+
const scale = Number.isFinite(viewBoxWidth) && viewBoxWidth > 0
|
|
1281
|
+
? Math.min(1, availableDiagramWidth / viewBoxWidth) : Number.NaN;
|
|
1282
|
+
const projected = entries.map((entry) => ({
|
|
1283
|
+
...entry,
|
|
1284
|
+
viewBoxWidth,
|
|
1285
|
+
scale,
|
|
1286
|
+
projectedFontPx: projectedNodeTextPx(entry.sourceFontPx, viewBoxWidth, availableDiagramWidth),
|
|
1287
|
+
}));
|
|
1288
|
+
const worst = projected.reduce((current, entry) => (
|
|
1289
|
+
!current || entry.projectedFontPx < current.projectedFontPx ? entry : current
|
|
1290
|
+
), null);
|
|
1291
|
+
const projectedMinimumTextPx = Number.isFinite(worst?.projectedFontPx) ? worst.projectedFontPx : null;
|
|
1292
|
+
const hardFloorMet = Number.isFinite(worst?.projectedFontPx)
|
|
1293
|
+
? worst.projectedFontPx >= MIN_PROJECTED_NODE_TEXT_PX : null;
|
|
1294
|
+
const requestedTargetMet = worst && Number.isFinite(requestedMinimumTextPx)
|
|
1295
|
+
? worst.projectedFontPx >= requestedMinimumTextPx : null;
|
|
1296
|
+
const evidence = {
|
|
1297
|
+
budgetBasis,
|
|
1298
|
+
readerContract: contract,
|
|
1299
|
+
availableDiagramWidth,
|
|
1300
|
+
actualBudgetPx: availableDiagramWidth,
|
|
1301
|
+
...(declared ? {
|
|
1302
|
+
actualReaderWidth: declared.actualReaderWidth,
|
|
1303
|
+
desiredReaderWidth: declared.desiredReaderWidth,
|
|
1304
|
+
viewportCap: declared.viewportCap,
|
|
1305
|
+
limit: declared.limit,
|
|
1306
|
+
} : { limit: 'legacy' }),
|
|
1307
|
+
requestedTargetPx: Number.isFinite(requestedMinimumTextPx) ? requestedMinimumTextPx : null,
|
|
1308
|
+
requestedTargetMet,
|
|
1309
|
+
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
1310
|
+
hardFloorMet,
|
|
1311
|
+
minimumOwner: worst?.owner || null,
|
|
1312
|
+
minimumSourceTextPx: worst?.sourceFontPx ?? null,
|
|
1313
|
+
minimumProjectedTextPx: projectedMinimumTextPx,
|
|
1314
|
+
semanticTextCount: entries.length,
|
|
1315
|
+
};
|
|
1316
|
+
return { evidence, issue: worst && hardFloorMet === false ? worst : null };
|
|
651
1317
|
}
|
|
652
1318
|
|
|
653
1319
|
function estimatedTextWidth(text, fontSize) {
|
|
@@ -656,88 +1322,6 @@ function estimatedTextWidth(text, fontSize) {
|
|
|
656
1322
|
return Math.max(fontSize, units * fontSize);
|
|
657
1323
|
}
|
|
658
1324
|
|
|
659
|
-
function pointsFromPath(d) {
|
|
660
|
-
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
|
661
|
-
const points = [];
|
|
662
|
-
let i = 0;
|
|
663
|
-
let command = '';
|
|
664
|
-
let current = [0, 0];
|
|
665
|
-
let start = null;
|
|
666
|
-
|
|
667
|
-
while (i < tokens.length) {
|
|
668
|
-
if (isCommand(tokens[i])) command = tokens[i++];
|
|
669
|
-
if (!command) break;
|
|
670
|
-
|
|
671
|
-
const absolute = command === command.toUpperCase();
|
|
672
|
-
switch (command.toUpperCase()) {
|
|
673
|
-
case 'M':
|
|
674
|
-
case 'L': {
|
|
675
|
-
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
|
676
|
-
const x = Number.parseFloat(tokens[i++]);
|
|
677
|
-
const y = Number.parseFloat(tokens[i++]);
|
|
678
|
-
if (!Number.isFinite(x) || !Number.isFinite(y)) break;
|
|
679
|
-
current = absolute ? [x, y] : [current[0] + x, current[1] + y];
|
|
680
|
-
points.push(current);
|
|
681
|
-
if (!start) start = current;
|
|
682
|
-
}
|
|
683
|
-
break;
|
|
684
|
-
}
|
|
685
|
-
case 'H': {
|
|
686
|
-
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
687
|
-
const x = Number.parseFloat(tokens[i++]);
|
|
688
|
-
if (!Number.isFinite(x)) break;
|
|
689
|
-
current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
|
|
690
|
-
points.push(current);
|
|
691
|
-
}
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
case 'V': {
|
|
695
|
-
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
696
|
-
const y = Number.parseFloat(tokens[i++]);
|
|
697
|
-
if (!Number.isFinite(y)) break;
|
|
698
|
-
current = absolute ? [current[0], y] : [current[0], current[1] + y];
|
|
699
|
-
points.push(current);
|
|
700
|
-
}
|
|
701
|
-
break;
|
|
702
|
-
}
|
|
703
|
-
case 'Q': {
|
|
704
|
-
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
|
705
|
-
const controlX = Number.parseFloat(tokens[i++]);
|
|
706
|
-
const controlY = Number.parseFloat(tokens[i++]);
|
|
707
|
-
const endX = Number.parseFloat(tokens[i++]);
|
|
708
|
-
const endY = Number.parseFloat(tokens[i++]);
|
|
709
|
-
if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
|
|
710
|
-
const control = absolute
|
|
711
|
-
? [controlX, controlY]
|
|
712
|
-
: [current[0] + controlX, current[1] + controlY];
|
|
713
|
-
const end = absolute
|
|
714
|
-
? [endX, endY]
|
|
715
|
-
: [current[0] + endX, current[1] + endY];
|
|
716
|
-
const startPoint = current;
|
|
717
|
-
for (let step = 1; step <= 8; step += 1) {
|
|
718
|
-
const amount = step / 8;
|
|
719
|
-
const remaining = 1 - amount;
|
|
720
|
-
points.push([
|
|
721
|
-
remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
|
|
722
|
-
remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
|
|
723
|
-
]);
|
|
724
|
-
}
|
|
725
|
-
current = end;
|
|
726
|
-
}
|
|
727
|
-
break;
|
|
728
|
-
}
|
|
729
|
-
case 'Z': {
|
|
730
|
-
if (start) points.push(start);
|
|
731
|
-
break;
|
|
732
|
-
}
|
|
733
|
-
default:
|
|
734
|
-
return [];
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
return points.filter(isPoint);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
1325
|
function properSegmentIntersection(a, b, c, d) {
|
|
742
1326
|
const abC = crossProduct(a, b, c);
|
|
743
1327
|
const abD = crossProduct(a, b, d);
|
|
@@ -813,20 +1397,69 @@ function padBox(box, padding) {
|
|
|
813
1397
|
};
|
|
814
1398
|
}
|
|
815
1399
|
|
|
1400
|
+
// Only geometry/numeric attributes are scanned: authored prose such as node
|
|
1401
|
+
// tags, <title> text, aria-labels, or data-* attributes may legitimately
|
|
1402
|
+
// mention "NaN" or "Infinity" without any coordinate being non-finite.
|
|
1403
|
+
function collectNonFiniteAttrs(svg) {
|
|
1404
|
+
const details = [];
|
|
1405
|
+
const stack = [];
|
|
1406
|
+
for (const match of svg.matchAll(SVG_TAG_TOKEN)) {
|
|
1407
|
+
if (!match[2]) continue;
|
|
1408
|
+
const element = match[2].toLowerCase();
|
|
1409
|
+
if (match[1]) {
|
|
1410
|
+
const index = stack.map(entry => entry.element).lastIndexOf(element);
|
|
1411
|
+
if (index >= 0) stack.length = index;
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const parent = stack[stack.length - 1];
|
|
1415
|
+
const inSvg = element === 'svg'
|
|
1416
|
+
|| Boolean(parent?.inSvg && !SVG_HTML_INTEGRATION_POINTS.has(parent.element));
|
|
1417
|
+
if (inSvg) {
|
|
1418
|
+
for (const [name, value] of attrEntries(match[0])) {
|
|
1419
|
+
if (!isNumericAttr(element, name) || !NON_FINITE_TOKEN.test(decodeNumericReferences(value))) continue;
|
|
1420
|
+
details.push(`${match[2]} ${name}="${value}"`);
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
// HTML void elements do not open a context; SVG self-closing tags do not
|
|
1424
|
+
// either. A nested <svg> inside foreignObject restores SVG checking.
|
|
1425
|
+
if (!(inSvg ? /\/\s*>$/.test(match[0]) : HTML_VOID_ELEMENTS.has(element))) {
|
|
1426
|
+
stack.push({ element, inSvg });
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
return details;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function decodeNumericReferences(value) {
|
|
1433
|
+
// Numeric references can encode every letter of NaN/Infinity/undefined.
|
|
1434
|
+
// Decode once, locally: other checks and diagnostic evidence keep raw values.
|
|
1435
|
+
return value.replace(/&#(?:x([0-9a-f]+)|([0-9]+));?/gi, (_, hex, decimal) => {
|
|
1436
|
+
const point = Number.parseInt(hex || decimal, hex ? 16 : 10);
|
|
1437
|
+
return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
|
|
1438
|
+
? String.fromCodePoint(point) : '\uFFFD';
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function isNumericAttr(elementName, attrName) {
|
|
1443
|
+
const normalizedAttr = attrName.toLowerCase();
|
|
1444
|
+
return NUMERIC_ATTRS.has(normalizedAttr)
|
|
1445
|
+
|| ELEMENT_NUMERIC_ATTRS.get(elementName.toLowerCase())?.has(normalizedAttr);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
function attrEntries(tag) {
|
|
1449
|
+
return [...tag.matchAll(HTML_ATTRIBUTE)].map((match) => [
|
|
1450
|
+
match[1],
|
|
1451
|
+
match[2] ?? match[3] ?? match[4],
|
|
1452
|
+
]);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
816
1455
|
function parseAttrs(tag) {
|
|
817
|
-
|
|
818
|
-
for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
|
|
819
|
-
return attrs;
|
|
1456
|
+
return Object.fromEntries(attrEntries(tag));
|
|
820
1457
|
}
|
|
821
1458
|
|
|
822
1459
|
function numberAttr(attrs, name) {
|
|
823
1460
|
return Number.parseFloat(attrs[name]);
|
|
824
1461
|
}
|
|
825
1462
|
|
|
826
|
-
function isCommand(token) {
|
|
827
|
-
return /^[A-Za-z]$/.test(token);
|
|
828
|
-
}
|
|
829
|
-
|
|
830
1463
|
function isPoint(point) {
|
|
831
1464
|
return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
|
|
832
1465
|
}
|