dsh-archify-projects 0.0.0-stage → 0.1.1
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/README.md +231 -2
- package/cordis.patch.yml +99 -0
- package/dist/client.js +2281 -0
- package/dist/client.js.map +1 -0
- package/docs/CONTAINER.md +197 -0
- package/docs/DESIGN.md +905 -0
- package/docs/HANDOVER.md +397 -0
- package/docs/PUBLISH.md +107 -0
- package/icon.svg +12 -0
- package/lib/host/archify-skill.js +116 -0
- package/lib/host/archify-skill.js.map +1 -0
- package/lib/host/config.js +95 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/context.js +28 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/git.js +185 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/http.js +184 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.js +90 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/policy.js +124 -0
- package/lib/host/policy.js.map +1 -0
- package/lib/host/routes.js +667 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/runner.js +913 -0
- package/lib/host/runner.js.map +1 -0
- package/lib/host/store.js +370 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/workspace.js +65 -0
- package/lib/host/workspace.js.map +1 -0
- package/lib/shared/format.js +138 -0
- package/lib/shared/format.js.map +1 -0
- package/lib/shared/prompt.js +257 -0
- package/lib/shared/prompt.js.map +1 -0
- package/lib/shared/protocol.js +31 -0
- package/lib/shared/protocol.js.map +1 -0
- package/lib/shared/types.js +173 -0
- package/lib/shared/types.js.map +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +98 -4
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +109 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
- package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/skills/archify/assets/template.html +13709 -0
- package/skills/archify/bin/archify.mjs +6936 -0
- package/skills/archify/bin/delivery-update.mjs +91 -0
- package/skills/archify/bin/finalize.mjs +895 -0
- package/skills/archify/bin/open-artifact.mjs +146 -0
- package/skills/archify/bin/preview.mjs +969 -0
- package/skills/archify/bin/recover-output.mjs +41 -0
- package/skills/archify/bin/visual-check.mjs +2667 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1313 -0
- package/skills/archify/examples/agent-run.lifecycle.json +55 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
- package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
- package/skills/archify/examples/event-stream.dataflow.json +52 -0
- package/skills/archify/examples/holiday-planning.workflow.json +143 -0
- package/skills/archify/examples/incident-response.workflow.json +59 -0
- package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
- package/skills/archify/examples/locales/de.json +425 -0
- package/skills/archify/examples/locales/es.json +440 -0
- package/skills/archify/examples/locales/fr.json +425 -0
- package/skills/archify/examples/locales/fr.partial.json +22 -0
- package/skills/archify/examples/locales/it.json +425 -0
- package/skills/archify/examples/locales/ja.json +425 -0
- package/skills/archify/examples/locales/ko.json +440 -0
- package/skills/archify/examples/locales/pt.json +425 -0
- package/skills/archify/examples/locales/ru.json +425 -0
- package/skills/archify/examples/product-analytics.dataflow.json +71 -0
- package/skills/archify/examples/production-deployment.architecture.json +51 -0
- package/skills/archify/examples/release-delivery.workflow.json +57 -0
- package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
- package/skills/archify/examples/web-app-rendered.html +13808 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
- package/skills/archify/migrations/workflow-v2.mjs +280 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +430 -0
- package/skills/archify/references/architecture-layout-repair.md +18 -0
- package/skills/archify/references/authoring-contract.md +419 -0
- package/skills/archify/references/authoring-defaults.md +41 -0
- package/skills/archify/references/brand-marks.md +82 -0
- package/skills/archify/references/delivery-contract.md +588 -0
- package/skills/archify/references/repository-authoring.md +105 -0
- package/skills/archify/references/update-awareness.md +12 -0
- package/skills/archify/references/viewer-runtime.md +35 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/labels.mjs +215 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
- package/skills/archify/renderers/architecture/routing.mjs +1157 -0
- package/skills/archify/renderers/dataflow/README.md +105 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
- package/skills/archify/renderers/lifecycle/README.md +171 -0
- package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
- package/skills/archify/renderers/sequence/README.md +129 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
- package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
- package/skills/archify/renderers/shared/cli.mjs +451 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
- package/skills/archify/renderers/shared/geometry.mjs +1941 -0
- package/skills/archify/renderers/shared/i18n.mjs +603 -0
- package/skills/archify/renderers/shared/layout-report.mjs +42 -0
- package/skills/archify/renderers/shared/legend.mjs +217 -0
- package/skills/archify/renderers/shared/output-path.mjs +440 -0
- package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
- package/skills/archify/renderers/shared/portable-path.mjs +408 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
- package/skills/archify/renderers/shared/repository-location.mjs +59 -0
- package/skills/archify/renderers/shared/route-quality.mjs +614 -0
- package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
- package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
- package/skills/archify/renderers/shared/text-fit.mjs +105 -0
- package/skills/archify/renderers/shared/utils.mjs +254 -0
- package/skills/archify/renderers/shared/validator.mjs +86 -0
- package/skills/archify/renderers/workflow/README.md +301 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/skills/archify/schemas/README.md +256 -0
- package/skills/archify/schemas/architecture.schema.json +152 -0
- package/skills/archify/schemas/common.schema.json +181 -0
- package/skills/archify/schemas/dataflow.schema.json +254 -0
- package/skills/archify/schemas/lifecycle.schema.json +280 -0
- package/skills/archify/schemas/sequence.schema.json +234 -0
- package/skills/archify/schemas/workflow.schema.json +439 -0
- package/skills/archify/scripts/check-render-output.mjs +1388 -0
- package/skills/archify/scripts/check-update.mjs +1710 -0
- package/skills/archify/scripts/delivery-update-child.mjs +23 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/scripts/update-contract.mjs +182 -0
- package/skills/archify/skill-release.json +10 -0
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { collectAmbiguousCorridors, collectArrowheadCollisions, collectBorderRuns, collectLabelCanvasOverflow, collectLabelRouteClearance, collectRouteRhythmIssues, describeLabelCanvasOverflow, formatRect, forwardCollinearAnalysisSegments, minimumLabelRouteClearance, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
|
|
7
|
+
import {
|
|
8
|
+
DESKTOP_READABILITY_VIEWPORT,
|
|
9
|
+
DESKTOP_READER_DIAGRAM_WIDTH,
|
|
10
|
+
DECLARED_WIDE_READER_CONTRACT,
|
|
11
|
+
declaredWideReadabilityBudget,
|
|
12
|
+
MIN_PROJECTED_NODE_TEXT_PX,
|
|
13
|
+
describeFixedWidthOverflow,
|
|
14
|
+
predictedFixedWidthOverflow,
|
|
15
|
+
projectedNodeTextPx,
|
|
16
|
+
} from '../renderers/shared/desktop-readability.mjs';
|
|
17
|
+
|
|
18
|
+
const input = process.argv[2];
|
|
19
|
+
|
|
20
|
+
if (!input || input === '-h' || input === '--help') {
|
|
21
|
+
console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
|
|
22
|
+
process.exit(input ? 0 : 2);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const htmlPath = path.resolve(input);
|
|
26
|
+
let html;
|
|
27
|
+
let artifact;
|
|
28
|
+
try {
|
|
29
|
+
const bytes = fs.readFileSync(htmlPath);
|
|
30
|
+
html = bytes.toString('utf8');
|
|
31
|
+
artifact = { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error(JSON.stringify({
|
|
34
|
+
ok: false,
|
|
35
|
+
file: htmlPath,
|
|
36
|
+
checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
|
|
37
|
+
}, null, 2));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const checks = [];
|
|
42
|
+
let composition = {
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
profile: 'standard',
|
|
45
|
+
status: 'pass',
|
|
46
|
+
summary: { errors: 0, warnings: 0 },
|
|
47
|
+
metrics: {
|
|
48
|
+
properCrossings: 0,
|
|
49
|
+
resolvedCrossovers: 0,
|
|
50
|
+
ambiguousCorridors: 0,
|
|
51
|
+
containerBorderRuns: 0,
|
|
52
|
+
labelRouteClearanceIssues: 0,
|
|
53
|
+
minLabelRouteClearance: null,
|
|
54
|
+
labelCanvasOverflowIssues: 0,
|
|
55
|
+
maxBends: 0,
|
|
56
|
+
routesOverSuggestedBends: 0,
|
|
57
|
+
maxStretch: null,
|
|
58
|
+
routesOverSuggestedStretch: 0,
|
|
59
|
+
minSegmentPx: null,
|
|
60
|
+
minInteriorSegmentPx: null,
|
|
61
|
+
shortSegmentCount: 0,
|
|
62
|
+
shortEndpointSegmentCount: 0,
|
|
63
|
+
shortInteriorSegmentCount: 0,
|
|
64
|
+
microSegmentCount: 0,
|
|
65
|
+
desktopReadabilityIssues: 0,
|
|
66
|
+
minProjectedNodeTextPx: null,
|
|
67
|
+
},
|
|
68
|
+
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
|
69
|
+
issues: [],
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const NON_FINITE_TOKEN = /\b(?:NaN|undefined|Infinity)\b/;
|
|
73
|
+
// Consume comments/CDATA as whole tokens, including any tag-like prose.
|
|
74
|
+
const SVG_TAG_TOKEN = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<(\/?)([A-Za-z][\w:-]*)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>/g;
|
|
75
|
+
const AUTOMATIC_CROSSOVER_UNDERLAY_TAG = /<path\b[^>]*\bdata-graph-role="automatic-crossover-underlay"[^>]*\/>/i;
|
|
76
|
+
const HTML_VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
77
|
+
const SVG_HTML_INTEGRATION_POINTS = new Set(['foreignobject', 'desc', 'title']);
|
|
78
|
+
const HTML_ATTRIBUTE = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
79
|
+
const readerContract = readerContractFromHtml(html);
|
|
80
|
+
const NUMERIC_ATTRS = new Set([
|
|
81
|
+
'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy',
|
|
82
|
+
'fr', 'width', 'height', 'd', 'points', 'pathlength', 'transform', 'viewbox', 'offset',
|
|
83
|
+
'opacity', 'fill-opacity', 'flood-opacity', 'stop-opacity', 'stroke-opacity', 'font-size',
|
|
84
|
+
'font-size-adjust', 'font-weight', 'letter-spacing', 'word-spacing', 'kerning', 'stroke-width',
|
|
85
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'textlength', 'startoffset',
|
|
86
|
+
'rotate', 'markerwidth', 'markerheight', 'refx', 'refy', 'orient', 'patterntransform',
|
|
87
|
+
'gradienttransform', 'filterres', 'stddeviation', 'basefrequency', 'numoctaves', 'seed',
|
|
88
|
+
'surfacescale', 'diffuseconstant', 'specularconstant', 'specularexponent',
|
|
89
|
+
'limitingconeangle', 'azimuth', 'elevation', 'pointsatx', 'pointsaty', 'pointsatz',
|
|
90
|
+
'kernelmatrix', 'order', 'divisor', 'bias', 'targetx', 'targety', 'kernelunitlength',
|
|
91
|
+
'scale', 'radius', 'k1', 'k2', 'k3', 'k4', 'tablevalues', 'slope', 'intercept',
|
|
92
|
+
'amplitude', 'exponent',
|
|
93
|
+
]);
|
|
94
|
+
const ELEMENT_NUMERIC_ATTRS = new Map([
|
|
95
|
+
['fecolormatrix', new Set(['values'])],
|
|
96
|
+
['fepointlight', new Set(['z'])],
|
|
97
|
+
['fespotlight', new Set(['z'])],
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
function addCheck(name, ok, details = []) {
|
|
101
|
+
checks.push({ name, ok, details });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
|
|
105
|
+
addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
|
|
106
|
+
|
|
107
|
+
if (svgMatches.length === 1) {
|
|
108
|
+
const svg = svgMatches[0][0];
|
|
109
|
+
const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
|
|
110
|
+
const svgAttrs = parseAttrs(svgRoot);
|
|
111
|
+
const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
|
|
112
|
+
const workflowV2 = svgAttrs['data-layout-contract'] === 'readable-v2';
|
|
113
|
+
const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
|
|
114
|
+
const nonFiniteAttrs = collectNonFiniteAttrs(svg);
|
|
115
|
+
addCheck('finite_svg', nonFiniteAttrs.length === 0, nonFiniteAttrs);
|
|
116
|
+
const legendStart = svg.indexOf('<!-- Legend -->');
|
|
117
|
+
const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
|
|
118
|
+
const desktopReadability = collectDesktopReadability(svgAttrs, beforeLegend, readerContract);
|
|
119
|
+
const desktopReadabilityIssue = desktopReadability.issue;
|
|
120
|
+
// The browser gate measures this later; the geometry is already certain here.
|
|
121
|
+
const viewportHeightIssue = predictedFixedWidthOverflow({
|
|
122
|
+
viewBoxWidth: viewBoxSize(svgAttrs)[0],
|
|
123
|
+
viewBoxHeight: viewBoxSize(svgAttrs)[1],
|
|
124
|
+
readerFit: svgAttrs['data-reader-fit'] || null,
|
|
125
|
+
diagramType: svgAttrs['data-diagram-type'] || null,
|
|
126
|
+
});
|
|
127
|
+
const arrows = collectArrows(beforeLegend, workflowV2);
|
|
128
|
+
const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
|
|
129
|
+
addCheck(
|
|
130
|
+
'orthogonal_arrows',
|
|
131
|
+
diagonal.length === 0,
|
|
132
|
+
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`),
|
|
133
|
+
);
|
|
134
|
+
const measuredRelationshipCrossings = collectRelationshipCrossings(arrows, workflowV2);
|
|
135
|
+
const resolvedCrossovers = measuredRelationshipCrossings.filter((hit) => (
|
|
136
|
+
hit.left.crossoverHalo && hit.right.crossoverHalo
|
|
137
|
+
));
|
|
138
|
+
const relationshipCrossings = measuredRelationshipCrossings.filter((hit) => (
|
|
139
|
+
!hit.left.crossoverHalo || !hit.right.crossoverHalo
|
|
140
|
+
));
|
|
141
|
+
const compositionFrames = collectCompositionFrames(beforeLegend);
|
|
142
|
+
const containerBorderRuns = collectBorderRuns({
|
|
143
|
+
routedRelations: arrows
|
|
144
|
+
.filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
|
|
145
|
+
.map((arrow) => ({
|
|
146
|
+
relation: arrow,
|
|
147
|
+
relationIndex: arrow.index,
|
|
148
|
+
segments: arrow.borderSegments,
|
|
149
|
+
})),
|
|
150
|
+
frames: compositionFrames,
|
|
151
|
+
});
|
|
152
|
+
const routedRelationships = arrows
|
|
153
|
+
.filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
|
|
154
|
+
.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
|
|
155
|
+
const nodeRects = svgAttrs.transform ? [] : collectUntransformedNodeRects(beforeLegend);
|
|
156
|
+
const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
|
|
157
|
+
// Architecture marks only one of its two Reader fits with its type.
|
|
158
|
+
const crowdedSides = svgAttrs['data-diagram-type'] === 'architecture' || svgAttrs['data-reader-primary-text'] === '14'
|
|
159
|
+
? crowdedNodeSides(arrows, nodeRects) : [];
|
|
160
|
+
const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
|
|
161
|
+
const ambiguousCorridors = collectAmbiguousCorridors({
|
|
162
|
+
routedRelations: routedRelationships,
|
|
163
|
+
includeSharedEndpoints: (left, right) => workflowV2 || (left.independentPorts && right.independentPorts),
|
|
164
|
+
allowShortWorkflowTrunks: workflowV2,
|
|
165
|
+
includeSharedEndpointCounterflow: (left, right) => left.automaticWorkflowRoute && right.automaticWorkflowRoute,
|
|
166
|
+
});
|
|
167
|
+
const arrowheadCollisions = collectArrowheadCollisions({
|
|
168
|
+
routedRelations: routedRelationships.filter((entry) => workflowV2 || entry.relation.independentPorts),
|
|
169
|
+
allowShortWorkflowTrunks: workflowV2,
|
|
170
|
+
});
|
|
171
|
+
const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
|
|
172
|
+
const leadingSpace = collectArchitectureLeadingSpace({
|
|
173
|
+
svgAttrs, fragment: beforeLegend, nodeRects, frames: compositionFrames,
|
|
174
|
+
arrows, labels: relationshipLabels,
|
|
175
|
+
});
|
|
176
|
+
const sequenceColumnSpace = collectSequenceColumnSpace({ svgAttrs, fragment: beforeLegend, nodeRects, arrows });
|
|
177
|
+
const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
|
|
178
|
+
const labelRouteMeasurements = collectLabelRouteClearance({
|
|
179
|
+
labels: relationshipLabels,
|
|
180
|
+
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
181
|
+
threshold: Number.MAX_VALUE,
|
|
182
|
+
});
|
|
183
|
+
const labelRouteClearance = collectLabelRouteClearance({
|
|
184
|
+
labels: relationshipLabels,
|
|
185
|
+
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
186
|
+
threshold: labelClearanceThreshold,
|
|
187
|
+
});
|
|
188
|
+
// The renderers bound their own label rects, but `check` also re-measures an
|
|
189
|
+
// artifact it did not produce; see collectLabelCanvasOverflow in
|
|
190
|
+
// shared/geometry.mjs.
|
|
191
|
+
const labelCanvasOverflow = collectLabelCanvasOverflow({
|
|
192
|
+
labels: relationshipLabels,
|
|
193
|
+
viewBox: viewBoxRect(svgAttrs),
|
|
194
|
+
});
|
|
195
|
+
const crossingIsError = qualityProfile === 'showcase';
|
|
196
|
+
const corridorIsError = qualityProfile === 'showcase';
|
|
197
|
+
const rhythmIsError = qualityProfile === 'showcase';
|
|
198
|
+
const labelClearanceIsError = qualityProfile === 'showcase';
|
|
199
|
+
const labelContainmentIsError = qualityProfile === 'showcase';
|
|
200
|
+
const desktopReadabilityIsError = qualityProfile === 'showcase';
|
|
201
|
+
// Certain geometry, but new: surface it as evidence first (CONTRIBUTING.md#product-and-compatibility-contracts).
|
|
202
|
+
const viewportHeightIsError = false;
|
|
203
|
+
const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
|
|
204
|
+
+ (crossingIsError ? relationshipCrossings.length : 0)
|
|
205
|
+
+ (corridorIsError ? ambiguousCorridors.length + arrowheadCollisions.length : 0)
|
|
206
|
+
+ (labelClearanceIsError ? labelRouteClearance.length : 0)
|
|
207
|
+
+ (labelContainmentIsError ? labelCanvasOverflow.length : 0)
|
|
208
|
+
+ (rhythmIsError ? routeRhythmIssues.length : 0)
|
|
209
|
+
+ (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0)
|
|
210
|
+
+ (viewportHeightIsError && viewportHeightIssue ? 1 : 0);
|
|
211
|
+
const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
|
|
212
|
+
+ (crossingIsError ? 0 : relationshipCrossings.length)
|
|
213
|
+
+ (corridorIsError ? 0 : ambiguousCorridors.length + arrowheadCollisions.length)
|
|
214
|
+
+ (labelClearanceIsError ? 0 : labelRouteClearance.length)
|
|
215
|
+
+ (labelContainmentIsError ? 0 : labelCanvasOverflow.length)
|
|
216
|
+
+ (rhythmIsError ? 0 : routeRhythmIssues.length)
|
|
217
|
+
+ (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1)
|
|
218
|
+
+ (viewportHeightIsError || !viewportHeightIssue ? 0 : 1);
|
|
219
|
+
composition = {
|
|
220
|
+
schemaVersion: 1,
|
|
221
|
+
profile: qualityProfile,
|
|
222
|
+
status: compositionErrors ? 'fail' : 'pass',
|
|
223
|
+
summary: {
|
|
224
|
+
errors: compositionErrors,
|
|
225
|
+
warnings: compositionWarnings,
|
|
226
|
+
},
|
|
227
|
+
metrics: {
|
|
228
|
+
properCrossings: relationshipCrossings.length,
|
|
229
|
+
resolvedCrossovers: resolvedCrossovers.length,
|
|
230
|
+
ambiguousCorridors: ambiguousCorridors.length,
|
|
231
|
+
arrowheadCollisions: arrowheadCollisions.length,
|
|
232
|
+
containerBorderRuns: containerBorderRuns.length,
|
|
233
|
+
labelRouteClearanceIssues: labelRouteClearance.length,
|
|
234
|
+
labelCanvasOverflowIssues: labelCanvasOverflow.length,
|
|
235
|
+
minLabelRouteClearance: minimumLabelRouteClearance(labelRouteMeasurements),
|
|
236
|
+
desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
|
|
237
|
+
viewportHeightIssues: viewportHeightIssue ? 1 : 0,
|
|
238
|
+
minProjectedNodeTextPx: desktopReadability.evidence.minimumProjectedTextPx,
|
|
239
|
+
...roundedRouteMetrics(routeMetrics),
|
|
240
|
+
},
|
|
241
|
+
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
|
242
|
+
// Perceptual review needs the affected relationships, not just a total.
|
|
243
|
+
// These are review evidence, not new pass/fail thresholds: a short, clear
|
|
244
|
+
// crossover can be preferable to a long crossing-free detour.
|
|
245
|
+
routeReview: {
|
|
246
|
+
crossings: resolvedCrossovers.map((hit) => {
|
|
247
|
+
const shared = [hit.left.from, hit.left.to].find((id) => id && (id === hit.right.from || id === hit.right.to));
|
|
248
|
+
return {
|
|
249
|
+
left: relationshipRecord(hit.left),
|
|
250
|
+
right: relationshipRecord(hit.right),
|
|
251
|
+
point: hit.point,
|
|
252
|
+
...(shared ? { sharedNode: shared } : {}),
|
|
253
|
+
};
|
|
254
|
+
}),
|
|
255
|
+
...(crowdedSides.length ? { crowdedSides } : {}),
|
|
256
|
+
detours: routedRelationships.flatMap((entry) => {
|
|
257
|
+
const metrics = routeBudgetMetrics({ routedRelations: [entry] });
|
|
258
|
+
const blockers = directCorridorBlockers(entry.relation, nodeRects);
|
|
259
|
+
return metrics.routesOverSuggestedBends || metrics.routesOverSuggestedStretch ? [{
|
|
260
|
+
relationship: relationshipRecord(entry.relation),
|
|
261
|
+
bends: metrics.maxBends,
|
|
262
|
+
stretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
|
|
263
|
+
...(blockers.length ? { directCorridorBlockers: blockers } : {}),
|
|
264
|
+
}] : [];
|
|
265
|
+
}),
|
|
266
|
+
},
|
|
267
|
+
leadingSpace,
|
|
268
|
+
...(sequenceColumnSpace ? { sequenceColumnSpace } : {}),
|
|
269
|
+
desktopReadability: desktopReadability.evidence,
|
|
270
|
+
issues: [
|
|
271
|
+
...containerBorderRuns.map((hit) => ({
|
|
272
|
+
severity: qualityGatesEnforced ? 'error' : 'warning',
|
|
273
|
+
code: 'composition/container-border-run',
|
|
274
|
+
relationship: relationshipRecord(hit.relation),
|
|
275
|
+
frame: frameRecord(hit.frame),
|
|
276
|
+
side: hit.side,
|
|
277
|
+
segmentIndex: hit.segmentIndex,
|
|
278
|
+
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
|
279
|
+
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
|
280
|
+
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
|
281
|
+
})),
|
|
282
|
+
...labelRouteClearance.map((hit) => ({
|
|
283
|
+
severity: labelClearanceIsError ? 'error' : 'warning',
|
|
284
|
+
code: 'composition/label-route-clearance',
|
|
285
|
+
label: hit.label?.label || hit.labelRelation?.label || '',
|
|
286
|
+
labelRelationship: relationshipRecord(hit.labelRelation),
|
|
287
|
+
otherRelationship: relationshipRecord(hit.otherRelation),
|
|
288
|
+
segmentIndex: hit.segmentIndex,
|
|
289
|
+
labelRect: roundedRect(hit.rect),
|
|
290
|
+
clearance: Math.round(hit.clearance * 10) / 10,
|
|
291
|
+
intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
|
|
292
|
+
threshold: hit.threshold,
|
|
293
|
+
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
|
294
|
+
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
|
295
|
+
})),
|
|
296
|
+
...labelCanvasOverflow.map((hit) => {
|
|
297
|
+
const label = hit.label?.label || hit.relation?.label || '';
|
|
298
|
+
return {
|
|
299
|
+
severity: labelContainmentIsError ? 'error' : 'warning',
|
|
300
|
+
code: 'composition/label-canvas-containment',
|
|
301
|
+
label,
|
|
302
|
+
relationship: relationshipRecord(hit.relation),
|
|
303
|
+
labelRect: roundedRect(hit.rect),
|
|
304
|
+
viewBox: hit.viewBox,
|
|
305
|
+
viewBoxOrigin: hit.viewBoxOrigin,
|
|
306
|
+
overflowPx: hit.overflowPx,
|
|
307
|
+
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.`,
|
|
308
|
+
};
|
|
309
|
+
}),
|
|
310
|
+
...relationshipCrossings.map((hit) => ({
|
|
311
|
+
severity: crossingIsError ? 'error' : 'warning',
|
|
312
|
+
code: 'composition/proper-crossing',
|
|
313
|
+
relationship: relationshipRecord(hit.left),
|
|
314
|
+
otherRelationship: relationshipRecord(hit.right),
|
|
315
|
+
point: hit.point.map((value) => Math.round(value * 10) / 10),
|
|
316
|
+
})),
|
|
317
|
+
...ambiguousCorridors.map((hit) => ({
|
|
318
|
+
severity: corridorIsError ? 'error' : 'warning',
|
|
319
|
+
code: 'composition/ambiguous-corridor',
|
|
320
|
+
relationship: relationshipRecord(hit.left.relation),
|
|
321
|
+
otherRelationship: relationshipRecord(hit.right.relation),
|
|
322
|
+
segmentIndex: hit.leftSegment,
|
|
323
|
+
otherSegmentIndex: hit.rightSegment,
|
|
324
|
+
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
|
325
|
+
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
|
326
|
+
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
|
327
|
+
})),
|
|
328
|
+
...arrowheadCollisions.map((hit) => ({
|
|
329
|
+
severity: corridorIsError ? 'error' : 'warning',
|
|
330
|
+
code: 'composition/arrowhead-collision',
|
|
331
|
+
relationship: relationshipRecord(hit.left.relation),
|
|
332
|
+
otherRelationship: relationshipRecord(hit.right.relation),
|
|
333
|
+
distancePx: hit.distance,
|
|
334
|
+
minimumPx: hit.minimum,
|
|
335
|
+
endpoints: [hit.left.tip, hit.right.tip],
|
|
336
|
+
})),
|
|
337
|
+
...routeRhythmIssues.map((hit) => ({
|
|
338
|
+
severity: rhythmIsError ? 'error' : 'warning',
|
|
339
|
+
code: hit.code,
|
|
340
|
+
relationship: relationshipRecord(hit.relation),
|
|
341
|
+
segmentIndex: hit.segmentIndex,
|
|
342
|
+
position: hit.position,
|
|
343
|
+
length: Math.round(hit.length * 10) / 10,
|
|
344
|
+
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
|
345
|
+
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
|
346
|
+
})),
|
|
347
|
+
...(desktopReadabilityIssue ? [{
|
|
348
|
+
severity: desktopReadabilityIsError ? 'error' : 'warning',
|
|
349
|
+
code: 'composition/desktop-readability',
|
|
350
|
+
...(desktopReadabilityIssue.nodeId ? { nodeId: desktopReadabilityIssue.nodeId } : {}),
|
|
351
|
+
owner: desktopReadabilityIssue.owner,
|
|
352
|
+
viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
|
|
353
|
+
viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
|
|
354
|
+
availableDiagramWidth: desktopReadability.evidence.availableDiagramWidth,
|
|
355
|
+
budgetBasis: desktopReadability.evidence.budgetBasis,
|
|
356
|
+
readerContract: desktopReadability.evidence.readerContract,
|
|
357
|
+
requestedTargetPx: desktopReadability.evidence.requestedTargetPx,
|
|
358
|
+
requestedTargetMet: desktopReadability.evidence.requestedTargetMet,
|
|
359
|
+
budgetLimit: desktopReadability.evidence.limit,
|
|
360
|
+
viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
|
|
361
|
+
scale: desktopReadabilityIssue.scale,
|
|
362
|
+
text: desktopReadabilityIssue.text,
|
|
363
|
+
detail: desktopReadabilityIssue.detail,
|
|
364
|
+
sourceFontPx: desktopReadabilityIssue.sourceFontPx,
|
|
365
|
+
projectedFontPx: desktopReadabilityIssue.projectedFontPx,
|
|
366
|
+
minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
367
|
+
}] : []),
|
|
368
|
+
...(viewportHeightIssue ? [{
|
|
369
|
+
severity: viewportHeightIsError ? 'error' : 'warning',
|
|
370
|
+
code: 'composition/viewport-height',
|
|
371
|
+
readerFit: svgAttrs['data-reader-fit'] || null,
|
|
372
|
+
viewBoxWidth: viewBoxSize(svgAttrs)[0],
|
|
373
|
+
viewBoxHeight: viewBoxSize(svgAttrs)[1],
|
|
374
|
+
...viewportHeightIssue,
|
|
375
|
+
detail: `[composition/viewport-height] ${describeFixedWidthOverflow({ ...viewportHeightIssue, viewBoxWidth: viewBoxSize(svgAttrs)[0], viewBoxHeight: viewBoxSize(svgAttrs)[1] })}`,
|
|
376
|
+
}] : []),
|
|
377
|
+
],
|
|
378
|
+
};
|
|
379
|
+
addCheck(
|
|
380
|
+
'label_route_clearance',
|
|
381
|
+
!labelClearanceIsError || labelRouteClearance.length === 0,
|
|
382
|
+
labelRouteClearance.map((hit) => (
|
|
383
|
+
`[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.`
|
|
384
|
+
)),
|
|
385
|
+
);
|
|
386
|
+
addCheck(
|
|
387
|
+
'relationship_crossings',
|
|
388
|
+
!crossingIsError || relationshipCrossings.length === 0,
|
|
389
|
+
relationshipCrossings.map((hit) => (
|
|
390
|
+
`[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
|
|
391
|
+
)),
|
|
392
|
+
);
|
|
393
|
+
addCheck(
|
|
394
|
+
'relationship_corridors',
|
|
395
|
+
!corridorIsError || ambiguousCorridors.length + arrowheadCollisions.length === 0,
|
|
396
|
+
[...ambiguousCorridors.map((hit) => (
|
|
397
|
+
`[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)}]`
|
|
398
|
+
)), ...arrowheadCollisions.map((hit) => (
|
|
399
|
+
`[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.`
|
|
400
|
+
))],
|
|
401
|
+
);
|
|
402
|
+
addCheck(
|
|
403
|
+
'container_border_runs',
|
|
404
|
+
!qualityGatesEnforced || containerBorderRuns.length === 0,
|
|
405
|
+
containerBorderRuns.map((hit) => (
|
|
406
|
+
`[composition/container-border-run] ${relationshipName(hit.relation)} follows ${frameName(hit.frame)} ${hit.side} border for ${Math.round(hit.overlapLength * 10) / 10}px on segment ${hit.segmentIndex} [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
|
|
407
|
+
)),
|
|
408
|
+
);
|
|
409
|
+
addCheck(
|
|
410
|
+
'route_rhythm',
|
|
411
|
+
!rhythmIsError || routeRhythmIssues.length === 0,
|
|
412
|
+
routeRhythmIssues.map((hit) => (
|
|
413
|
+
`[${hit.code}] ${qualityProfile} ${relationshipName(hit.relation)} has a ${Math.round(hit.length * 10) / 10}px ${hit.position} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]`
|
|
414
|
+
)),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (legendStart >= 0) {
|
|
418
|
+
const legendFragment = svg.slice(legendStart);
|
|
419
|
+
const legendBoxes = collectLegendBoxes(legendFragment);
|
|
420
|
+
const collisions = collectLegendCollisions(arrows, legendBoxes);
|
|
421
|
+
addCheck(
|
|
422
|
+
'legend_clearance',
|
|
423
|
+
collisions.length === 0,
|
|
424
|
+
collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
|
|
425
|
+
);
|
|
426
|
+
} else {
|
|
427
|
+
addCheck('legend_clearance', true, ['no legend marker found']);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
|
|
432
|
+
console.log(JSON.stringify({ ok, file: htmlPath, artifact, checks, composition }, null, 2));
|
|
433
|
+
// Let pending stdout writes drain: large receipts are asynchronous when piped.
|
|
434
|
+
process.exitCode = ok ? 0 : 1;
|
|
435
|
+
|
|
436
|
+
function collectArrows(fragment, useActualPoints = false) {
|
|
437
|
+
const arrows = [];
|
|
438
|
+
let index = 0;
|
|
439
|
+
let previousTag = null;
|
|
440
|
+
let previousTagEnd = 0;
|
|
441
|
+
|
|
442
|
+
for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
|
|
443
|
+
const tagStart = tag.index;
|
|
444
|
+
const raw = tag[0];
|
|
445
|
+
const gap = previousTag ? fragment.slice(previousTagEnd, tagStart) : '';
|
|
446
|
+
const precedingUnderlay = previousTag
|
|
447
|
+
&& /^\s*$/.test(gap)
|
|
448
|
+
&& previousTag.name === 'path'
|
|
449
|
+
&& previousTag.underlayAttrs;
|
|
450
|
+
previousTag = {
|
|
451
|
+
name: tag[1].toLowerCase(),
|
|
452
|
+
underlayAttrs: tag[1].toLowerCase() === 'path' && AUTOMATIC_CROSSOVER_UNDERLAY_TAG.test(raw)
|
|
453
|
+
? parseAttrs(raw)
|
|
454
|
+
: null,
|
|
455
|
+
};
|
|
456
|
+
previousTagEnd = tagStart + raw.length;
|
|
457
|
+
if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
|
|
458
|
+
if (!/\bmarker-end=/.test(raw)) continue;
|
|
459
|
+
const attrs = parseAttrs(raw);
|
|
460
|
+
const routeStrokeWidth = numberAttr(attrs, 'stroke-width');
|
|
461
|
+
const underlayStrokeWidth = precedingUnderlay ? numberAttr(precedingUnderlay, 'stroke-width') : NaN;
|
|
462
|
+
const verifiedCrossoverHalo = attrs['data-composition-crossover'] === 'halo'
|
|
463
|
+
&& precedingUnderlay?.d === attrs.d
|
|
464
|
+
&& precedingUnderlay?.fill === 'none'
|
|
465
|
+
&& precedingUnderlay?.stroke === 'var(--mask)'
|
|
466
|
+
&& precedingUnderlay?.['pointer-events'] === 'none'
|
|
467
|
+
&& Number.isFinite(routeStrokeWidth)
|
|
468
|
+
&& Number.isFinite(underlayStrokeWidth)
|
|
469
|
+
&& underlayStrokeWidth >= routeStrokeWidth + 3;
|
|
470
|
+
const segments = tag[1].toLowerCase() === 'line'
|
|
471
|
+
? lineSegments(attrs)
|
|
472
|
+
: pathSegments(attrs.d || '');
|
|
473
|
+
const borderSegments = tag[1].toLowerCase() === 'line'
|
|
474
|
+
? segments
|
|
475
|
+
: straightPathSegments(attrs.d || '');
|
|
476
|
+
arrows.push({
|
|
477
|
+
kind: tag[1].toLowerCase(),
|
|
478
|
+
index: index += 1,
|
|
479
|
+
raw,
|
|
480
|
+
// Trust route intent only for a semantic edge with one visible direct
|
|
481
|
+
// segment. A stale marker on bent/curved geometry cannot waive the gate.
|
|
482
|
+
authoredStraight: attrs['data-composition-route'] === 'straight'
|
|
483
|
+
&& Boolean(attrs['data-edge-from'] && attrs['data-edge-to'])
|
|
484
|
+
&& segments.length === 1 && borderSegments.length === 1
|
|
485
|
+
&& (tag[1].toLowerCase() === 'line' || /^\s*M\s+[-+\d.eE]+\s+[-+\d.eE]+\s+L\s+[-+\d.eE]+\s+[-+\d.eE]+\s*$/.test(attrs.d || '')),
|
|
486
|
+
crossoverHalo: verifiedCrossoverHalo,
|
|
487
|
+
independentPorts: verifiedCrossoverHalo && attrs['data-composition-independent'] === 'true',
|
|
488
|
+
// Compatibility with first-round exports lacking a root layout contract.
|
|
489
|
+
// Readable-v2's root contract supersedes this narrower automatic-pair rule.
|
|
490
|
+
// This marker never certifies a crossover halo or waives a quality rule.
|
|
491
|
+
automaticWorkflowRoute: attrs['data-composition-routing'] === 'workflow-v2-auto',
|
|
492
|
+
width: routeStrokeWidth,
|
|
493
|
+
variant: raw.match(/\ba-(default|emphasis|security|dashed)\b/)?.[1] || 'default',
|
|
494
|
+
role: attrs['data-edge-role'],
|
|
495
|
+
segments,
|
|
496
|
+
borderSegments,
|
|
497
|
+
routePoints: (!useActualPoints && parseRoutePoints(attrs['data-composition-points'])) || (
|
|
498
|
+
borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
|
|
499
|
+
),
|
|
500
|
+
from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
|
|
501
|
+
to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
|
|
502
|
+
id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
|
|
503
|
+
key: attrs['data-edge-key'],
|
|
504
|
+
label: attrs['data-edge-label'],
|
|
505
|
+
offset: tag.index,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return arrows;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function collectRelationshipLabelMasks(fragment, arrows) {
|
|
513
|
+
const labels = [];
|
|
514
|
+
for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
|
|
515
|
+
const group = match[0];
|
|
516
|
+
const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
|
|
517
|
+
const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
|
|
518
|
+
.map((item) => item[0])
|
|
519
|
+
.find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
|
|
520
|
+
if (!rectTag) continue;
|
|
521
|
+
const attrs = parseAttrs(rectTag);
|
|
522
|
+
const rect = {
|
|
523
|
+
x: numberAttr(attrs, 'x'),
|
|
524
|
+
y: numberAttr(attrs, 'y'),
|
|
525
|
+
width: numberAttr(attrs, 'width'),
|
|
526
|
+
height: numberAttr(attrs, 'height'),
|
|
527
|
+
};
|
|
528
|
+
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
|
|
529
|
+
const groupStart = match.index;
|
|
530
|
+
const groupEnd = groupStart + group.length;
|
|
531
|
+
const containedOwner = arrows.find((arrow) => (
|
|
532
|
+
arrow.offset > groupStart
|
|
533
|
+
&& arrow.offset < groupEnd
|
|
534
|
+
&& arrow.from === groupAttrs['data-edge-from']
|
|
535
|
+
&& arrow.to === groupAttrs['data-edge-to']
|
|
536
|
+
&& (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
|
|
537
|
+
));
|
|
538
|
+
const owner = arrows.find((arrow) => (
|
|
539
|
+
groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
|
|
540
|
+
)) || containedOwner || arrows.find((arrow) => (
|
|
541
|
+
arrow.id === groupAttrs['data-edge-id']
|
|
542
|
+
&& arrow.from === groupAttrs['data-edge-from']
|
|
543
|
+
&& arrow.to === groupAttrs['data-edge-to']
|
|
544
|
+
));
|
|
545
|
+
if (!owner) continue;
|
|
546
|
+
if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
|
|
547
|
+
if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
|
|
548
|
+
if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
|
|
549
|
+
labels.push({
|
|
550
|
+
relation: owner,
|
|
551
|
+
relationIndex: owner.index,
|
|
552
|
+
label: groupAttrs['data-edge-label'] || '',
|
|
553
|
+
rect,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
return labels;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function roundedRect(rect) {
|
|
560
|
+
return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function roundedRouteMetrics(metrics) {
|
|
564
|
+
return {
|
|
565
|
+
...metrics,
|
|
566
|
+
maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
|
|
567
|
+
minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
|
|
568
|
+
minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseRoutePoints(value) {
|
|
573
|
+
if (!value) return null;
|
|
574
|
+
const points = value.split(';').map((pair) => pair.split(',').map(Number));
|
|
575
|
+
return points.length >= 2 && points.every(isPoint) ? points : null;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
|
|
579
|
+
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length).map(arrow => ({
|
|
580
|
+
...arrow,
|
|
581
|
+
// Readable-v2 routePoints come from the visible path, never its metadata.
|
|
582
|
+
// A straight-through via is not a visual endpoint; preserve real bends.
|
|
583
|
+
segments: includeSharedEndpoints ? forwardCollinearAnalysisSegments(arrow.routePoints) : arrow.segments,
|
|
584
|
+
}));
|
|
585
|
+
const crossings = [];
|
|
586
|
+
for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
|
|
587
|
+
const left = relationships[leftIndex];
|
|
588
|
+
for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
|
|
589
|
+
const right = relationships[rightIndex];
|
|
590
|
+
// A shared semantic endpoint does not make an interior X a junction
|
|
591
|
+
// when both paths opt into automatic workflow or independent-port checks.
|
|
592
|
+
if ([left.from, left.to].some((id) => id === right.from || id === right.to)
|
|
593
|
+
&& !includeSharedEndpoints
|
|
594
|
+
&& !(left.independentPorts && right.independentPorts)
|
|
595
|
+
&& !(left.automaticWorkflowRoute && right.automaticWorkflowRoute)) continue;
|
|
596
|
+
let point = null;
|
|
597
|
+
for (const leftSegment of left.segments) {
|
|
598
|
+
for (const rightSegment of right.segments) {
|
|
599
|
+
point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
|
|
600
|
+
if (point) break;
|
|
601
|
+
}
|
|
602
|
+
if (point) break;
|
|
603
|
+
}
|
|
604
|
+
if (point) crossings.push({ left, right, point });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return crossings;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function relationshipName(arrow) {
|
|
611
|
+
return arrow.id
|
|
612
|
+
? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
|
|
613
|
+
: `relationship "${arrow.from}" -> "${arrow.to}"`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Review evidence only. Mask rectangles are the visible node bounds emitted by
|
|
617
|
+
// our renderers. Skip transformed ancestry rather than mixing coordinate spaces.
|
|
618
|
+
function collectUntransformedNodeRects(fragment) {
|
|
619
|
+
const groups = [];
|
|
620
|
+
const nodes = new Map();
|
|
621
|
+
for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
622
|
+
if (!token[2]) continue;
|
|
623
|
+
const name = token[2].toLowerCase();
|
|
624
|
+
if (name === 'g') {
|
|
625
|
+
if (token[1]) groups.pop();
|
|
626
|
+
else if (!/\/\s*>$/.test(token[0])) groups.push(parseAttrs(token[0]));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (name !== 'rect' || token[1] || groups.some((group) => group.transform)) continue;
|
|
630
|
+
const attrs = parseAttrs(token[0]);
|
|
631
|
+
const owner = [...groups].reverse().find((group) => group['data-node-id']);
|
|
632
|
+
if (!owner || attrs.transform || !String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
|
|
633
|
+
const box = ['x', 'y', 'width', 'height'].map((key) => numberAttr(attrs, key));
|
|
634
|
+
if (!box.every(Number.isFinite) || box[2] <= 0 || box[3] <= 0) continue;
|
|
635
|
+
const id = owner['data-node-id'];
|
|
636
|
+
// Ambiguous ownership is not reliable evidence for moving a node.
|
|
637
|
+
nodes.set(id, nodes.has(id) ? null : { id, label: owner['data-node-label'] || id, box });
|
|
638
|
+
}
|
|
639
|
+
return [...nodes.values()].filter(Boolean);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function directCorridorBlockers(relation, nodes) {
|
|
643
|
+
const from = nodes.find((node) => node.id === relation.from);
|
|
644
|
+
const to = nodes.find((node) => node.id === relation.to);
|
|
645
|
+
if (!from || !to || from === to) return [];
|
|
646
|
+
const center = ({ box: [x, y, w, h] }) => [x + w / 2, y + h / 2];
|
|
647
|
+
const a = center(from);
|
|
648
|
+
const b = center(to);
|
|
649
|
+
const horizontal = Math.abs(a[1] - b[1]) < 0.01;
|
|
650
|
+
const vertical = Math.abs(a[0] - b[0]) < 0.01;
|
|
651
|
+
if (horizontal === vertical) return [];
|
|
652
|
+
const axis = horizontal ? 0 : 1;
|
|
653
|
+
const cross = 1 - axis;
|
|
654
|
+
const [first, last] = a[axis] < b[axis] ? [from, to] : [to, from];
|
|
655
|
+
const low = first.box[axis] + first.box[axis + 2];
|
|
656
|
+
const high = last.box[axis];
|
|
657
|
+
if (high <= low) return [];
|
|
658
|
+
return nodes.filter((node) => node !== from && node !== to
|
|
659
|
+
&& node.box[axis] < high && node.box[axis] + node.box[axis + 2] > low
|
|
660
|
+
&& node.box[cross] < a[cross] && node.box[cross] + node.box[cross + 2] > a[cross]);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Automatic ports need a 16px corner gutter and 14px between neighbours, so a
|
|
664
|
+
// side facing more counterparts than that fits pushes routes onto other sides.
|
|
665
|
+
function crowdedNodeSides(arrows, nodes) {
|
|
666
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
667
|
+
const demand = new Map();
|
|
668
|
+
for (const arrow of arrows) {
|
|
669
|
+
const from = byId.get(arrow.from);
|
|
670
|
+
const to = byId.get(arrow.to);
|
|
671
|
+
if (!from || !to || from === to) continue;
|
|
672
|
+
for (const [node, other] of [[from, to], [to, from]]) {
|
|
673
|
+
const [x, y, w, h] = node.box;
|
|
674
|
+
const dx = other.box[0] + other.box[2] / 2 - (x + w / 2);
|
|
675
|
+
const dy = other.box[1] + other.box[3] / 2 - (y + h / 2);
|
|
676
|
+
const side = dx !== 0 && Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy > 0 ? 'bottom' : 'top');
|
|
677
|
+
const key = `${node.id}\u0000${side}`;
|
|
678
|
+
const entry = demand.get(key) || { node, side, relationships: 0 };
|
|
679
|
+
entry.relationships += 1;
|
|
680
|
+
demand.set(key, entry);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return [...demand.values()].flatMap(({ node, side, relationships }) => {
|
|
684
|
+
const sidePx = side === 'left' || side === 'right' ? node.box[3] : node.box[2];
|
|
685
|
+
const neededPx = 32 + 14 * (relationships - 1);
|
|
686
|
+
return relationships > 1 && sidePx < neededPx
|
|
687
|
+
? [{ node: node.id, label: node.label, side, relationships, sidePx, neededPx }] : [];
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function relationshipRecord(arrow) {
|
|
692
|
+
const stableIndex = Number(arrow.key);
|
|
693
|
+
return {
|
|
694
|
+
id: arrow.id,
|
|
695
|
+
from: arrow.from,
|
|
696
|
+
to: arrow.to,
|
|
697
|
+
label: arrow.label || '',
|
|
698
|
+
collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
|
|
699
|
+
artifactIndex: arrow.index,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function collectCompositionFrames(fragment) {
|
|
704
|
+
const frames = [];
|
|
705
|
+
for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
|
|
706
|
+
const attrs = parseAttrs(match[0]);
|
|
707
|
+
const kind = attrs['data-composition-frame-kind'];
|
|
708
|
+
if (!kind) continue;
|
|
709
|
+
const identity = attrs['data-composition-frame-id'] || frames.length;
|
|
710
|
+
if (match[1].toLowerCase() === 'rect') {
|
|
711
|
+
const frame = {
|
|
712
|
+
kind,
|
|
713
|
+
id: identity,
|
|
714
|
+
x: numberAttr(attrs, 'x'),
|
|
715
|
+
y: numberAttr(attrs, 'y'),
|
|
716
|
+
width: numberAttr(attrs, 'width'),
|
|
717
|
+
height: numberAttr(attrs, 'height'),
|
|
718
|
+
radius: numberAttr(attrs, 'rx') || 0,
|
|
719
|
+
};
|
|
720
|
+
if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const segments = match[1].toLowerCase() === 'line'
|
|
724
|
+
? lineSegments(attrs)
|
|
725
|
+
: pathSegments(attrs.d || '');
|
|
726
|
+
for (const [segmentIndex, segment] of segments.entries()) {
|
|
727
|
+
frames.push({
|
|
728
|
+
kind,
|
|
729
|
+
id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
|
|
730
|
+
shape: 'line',
|
|
731
|
+
start: segment.start,
|
|
732
|
+
end: segment.end,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return frames;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Evidence for author review only. Automatic Architecture is identifiable by
|
|
740
|
+
// its intrinsic Reader contract; authored canvases must retain their geometry.
|
|
741
|
+
function collectArchitectureLeadingSpace({ svgAttrs, fragment, nodeRects, frames, arrows, labels }) {
|
|
742
|
+
const evidence = { measured: false, reviewSuggested: false };
|
|
743
|
+
if (svgAttrs['data-reader-fit'] !== 'intrinsic-height'
|
|
744
|
+
|| svgAttrs['data-reader-primary-text'] !== '14'
|
|
745
|
+
|| svgAttrs.transform
|
|
746
|
+
|| [...fragment.matchAll(/<g\b[^>]*\btransform\s*=[^>]*>/gi)]
|
|
747
|
+
.some((match) => !/\bdata-semantic-sigil=/.test(match[0]))
|
|
748
|
+
|| /<(?:path|line|rect|text)\b[^>]*\btransform\s*=/i.test(fragment)) return evidence;
|
|
749
|
+
const [originX, originY, width, height] = viewBoxRect(svgAttrs);
|
|
750
|
+
if (![originX, originY, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
|
|
751
|
+
const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
|
|
752
|
+
if (!nodeRects.length || nodeRects.length !== nodeCount) return evidence;
|
|
753
|
+
const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
|
|
754
|
+
if (semanticArrows.some((arrow) => !arrow.routePoints.length)) return evidence;
|
|
755
|
+
|
|
756
|
+
const occupied = nodeRects.map((node) => node.box[1]);
|
|
757
|
+
for (const frame of frames) {
|
|
758
|
+
const top = frame.shape === 'line'
|
|
759
|
+
? Math.min(frame.start[1], frame.end[1]) : frame.y;
|
|
760
|
+
if (!Number.isFinite(top)) return evidence;
|
|
761
|
+
occupied.push(top);
|
|
762
|
+
}
|
|
763
|
+
// Boundary titles can protrude above their structural frame.
|
|
764
|
+
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)) {
|
|
765
|
+
const rect = parseAttrs(match[0].match(/<rect\b[^>]*>/i)?.[0] || '');
|
|
766
|
+
const y = numberAttr(rect, 'y');
|
|
767
|
+
if (!Number.isFinite(y)) return evidence;
|
|
768
|
+
occupied.push(y);
|
|
769
|
+
}
|
|
770
|
+
for (const arrow of semanticArrows) {
|
|
771
|
+
for (const point of arrow.routePoints) occupied.push(point[1]);
|
|
772
|
+
}
|
|
773
|
+
for (const label of labels) occupied.push(label.rect.y);
|
|
774
|
+
if (!occupied.every(Number.isFinite)) return evidence;
|
|
775
|
+
const occupiedTop = Math.min(...occupied);
|
|
776
|
+
const gap = Math.max(0, occupiedTop - originY);
|
|
777
|
+
const heights = nodeRects.map((node) => node.box[3]).sort((a, b) => a - b);
|
|
778
|
+
const typicalNodeHeight = heights[Math.floor(heights.length / 2)];
|
|
779
|
+
const ratio = gap / height;
|
|
780
|
+
return {
|
|
781
|
+
measured: true,
|
|
782
|
+
emptyTopPx: Math.round(gap * 10) / 10,
|
|
783
|
+
emptyTopRatio: Math.round(ratio * 1000) / 1000,
|
|
784
|
+
occupiedTop: Math.round(occupiedTop * 10) / 10,
|
|
785
|
+
viewBoxTop: originY,
|
|
786
|
+
canvasHeight: height,
|
|
787
|
+
typicalNodeHeight,
|
|
788
|
+
reviewSuggested: gap > 2 * typicalNodeHeight && ratio > 0.2,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Advisory only: fixed columns are a compatibility contract. Measure semantic
|
|
793
|
+
// content, including long labels/notes, rather than treating every wide canvas
|
|
794
|
+
// as wasted space. Auto-sized segment frames do not add a participant column.
|
|
795
|
+
function collectSequenceColumnSpace({ svgAttrs, fragment, nodeRects, arrows }) {
|
|
796
|
+
const columnFit = svgAttrs['data-sequence-column-fit'];
|
|
797
|
+
if (!['fixed', 'spread'].includes(columnFit)) return null;
|
|
798
|
+
const evidence = { measured: false, reviewSuggested: false, columnFit };
|
|
799
|
+
if (svgAttrs.transform || /<tspan\b/i.test(fragment)) return evidence;
|
|
800
|
+
// Brand badges stay inside their participant box. Ignore only that subtree's
|
|
801
|
+
// transforms, including the preset path or nested fallback icon's scale.
|
|
802
|
+
const brandGroups = [];
|
|
803
|
+
for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
|
|
804
|
+
if (!token[2]) continue;
|
|
805
|
+
const name = token[2].toLowerCase();
|
|
806
|
+
if (token[1]) {
|
|
807
|
+
if (name === 'g') brandGroups.pop();
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const attrs = parseAttrs(token[0]);
|
|
811
|
+
const inBrand = brandGroups.at(-1) === true || (name === 'g'
|
|
812
|
+
&& Boolean(attrs['data-brand-mark'])
|
|
813
|
+
&& String(attrs.class || '').split(/\s+/).includes('brand-mark'));
|
|
814
|
+
if (!inBrand && attrs.transform && ['g', 'path', 'line', 'rect', 'text'].includes(name)
|
|
815
|
+
&& !(name === 'g' && attrs['data-semantic-sigil'])) return evidence;
|
|
816
|
+
if (name === 'g' && !/\/\s*>$/.test(token[0])) brandGroups.push(inBrand);
|
|
817
|
+
}
|
|
818
|
+
const [originX, , width, height] = viewBoxRect(svgAttrs);
|
|
819
|
+
if (![originX, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
|
|
820
|
+
const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
|
|
821
|
+
if (!nodeRects.length || nodeCount !== nodeRects.length) return evidence;
|
|
822
|
+
const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
|
|
823
|
+
if (semanticArrows.some((arrow) => !arrow.routePoints.length)) return evidence;
|
|
824
|
+
const rightEdges = nodeRects.map((node) => node.box[0] + node.box[2]);
|
|
825
|
+
for (const arrow of semanticArrows) {
|
|
826
|
+
for (const point of arrow.routePoints) rightEdges.push(point[0]);
|
|
827
|
+
}
|
|
828
|
+
// Label plates, activations and segment titles also reserve horizontal room.
|
|
829
|
+
for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
|
|
830
|
+
const attrs = parseAttrs(match[0]);
|
|
831
|
+
if (!String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
|
|
832
|
+
rightEdges.push(numberAttr(attrs, 'x') + numberAttr(attrs, 'width'));
|
|
833
|
+
}
|
|
834
|
+
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
835
|
+
const box = textBox(parseAttrs(match[1]), stripTags(match[2]).trim());
|
|
836
|
+
if (!box) return evidence;
|
|
837
|
+
rightEdges.push(box.x2);
|
|
838
|
+
}
|
|
839
|
+
if (!rightEdges.every(Number.isFinite)) return evidence;
|
|
840
|
+
const occupiedRight = Math.max(...rightEdges);
|
|
841
|
+
const gap = Math.max(0, originX + width - occupiedRight);
|
|
842
|
+
const ratio = gap / width;
|
|
843
|
+
const widths = nodeRects.map((node) => node.box[2]).sort((a, b) => a - b);
|
|
844
|
+
const typicalParticipantWidth = widths[Math.floor(widths.length / 2)];
|
|
845
|
+
return {
|
|
846
|
+
measured: true,
|
|
847
|
+
columnFit,
|
|
848
|
+
participantCount: nodeCount,
|
|
849
|
+
occupiedRight: Math.round(occupiedRight * 10) / 10,
|
|
850
|
+
viewBoxLeft: originX,
|
|
851
|
+
canvasWidth: width,
|
|
852
|
+
emptyRightPx: Math.round(gap * 10) / 10,
|
|
853
|
+
emptyRightRatio: Math.round(ratio * 1000) / 1000,
|
|
854
|
+
typicalParticipantWidth,
|
|
855
|
+
// Avoid stretching a small conversation merely to fill its canvas. These
|
|
856
|
+
// conservative review thresholds never contribute errors or warnings.
|
|
857
|
+
reviewSuggested: columnFit === 'fixed' && nodeCount >= 4
|
|
858
|
+
&& gap > 2 * typicalParticipantWidth && ratio > 0.25,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function frameName(frame) {
|
|
863
|
+
return `${frame.kind || 'frame'} "${frame.id}"`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function frameRecord(frame) {
|
|
867
|
+
return { kind: frame.kind, id: frame.id };
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function formatPoint(point) {
|
|
871
|
+
return point.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function lineSegments(attrs) {
|
|
875
|
+
const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
|
|
876
|
+
const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
|
|
877
|
+
if (!isPoint(start) || !isPoint(end)) return [];
|
|
878
|
+
return [{ start, end }];
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function pathSegments(d) {
|
|
882
|
+
const points = pointsFromPath(d);
|
|
883
|
+
const segments = [];
|
|
884
|
+
for (let i = 1; i < points.length; i += 1) {
|
|
885
|
+
segments.push({ start: points[i - 1], end: points[i] });
|
|
886
|
+
}
|
|
887
|
+
return segments;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Border runs use exact visible primitives. Non-collinear Q curves are never
|
|
891
|
+
// flattened into chords here: a tangent or sampled near-horizontal curve is
|
|
892
|
+
// not a structural border run. A fully collinear Q remains a straight visible
|
|
893
|
+
// primitive and is included.
|
|
894
|
+
function straightPathSegments(d) {
|
|
895
|
+
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
|
896
|
+
const segments = [];
|
|
897
|
+
let i = 0;
|
|
898
|
+
let command = '';
|
|
899
|
+
let current = [0, 0];
|
|
900
|
+
let start = null;
|
|
901
|
+
while (i < tokens.length) {
|
|
902
|
+
if (isCommand(tokens[i])) command = tokens[i++];
|
|
903
|
+
if (!command) break;
|
|
904
|
+
const absolute = command === command.toUpperCase();
|
|
905
|
+
switch (command.toUpperCase()) {
|
|
906
|
+
case 'M':
|
|
907
|
+
case 'L': {
|
|
908
|
+
let first = true;
|
|
909
|
+
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
|
910
|
+
const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
|
|
911
|
+
if (!point.every(Number.isFinite)) break;
|
|
912
|
+
const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
|
|
913
|
+
if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
|
|
914
|
+
current = next;
|
|
915
|
+
if (!start) start = current;
|
|
916
|
+
first = false;
|
|
917
|
+
}
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
case 'H': {
|
|
921
|
+
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
922
|
+
const value = Number.parseFloat(tokens[i++]);
|
|
923
|
+
if (!Number.isFinite(value)) break;
|
|
924
|
+
const next = [absolute ? value : current[0] + value, current[1]];
|
|
925
|
+
segments.push({ start: current, end: next });
|
|
926
|
+
current = next;
|
|
927
|
+
}
|
|
928
|
+
break;
|
|
929
|
+
}
|
|
930
|
+
case 'V': {
|
|
931
|
+
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
932
|
+
const value = Number.parseFloat(tokens[i++]);
|
|
933
|
+
if (!Number.isFinite(value)) break;
|
|
934
|
+
const next = [current[0], absolute ? value : current[1] + value];
|
|
935
|
+
segments.push({ start: current, end: next });
|
|
936
|
+
current = next;
|
|
937
|
+
}
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
case 'Q': {
|
|
941
|
+
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
|
942
|
+
const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
|
|
943
|
+
if (!values.every(Number.isFinite)) break;
|
|
944
|
+
const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
|
|
945
|
+
const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
|
|
946
|
+
if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
|
|
947
|
+
current = end;
|
|
948
|
+
}
|
|
949
|
+
break;
|
|
950
|
+
}
|
|
951
|
+
case 'Z':
|
|
952
|
+
if (start) segments.push({ start: current, end: start });
|
|
953
|
+
current = start || current;
|
|
954
|
+
command = '';
|
|
955
|
+
break;
|
|
956
|
+
default:
|
|
957
|
+
return [];
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function diagonalStraightSegments(arrow) {
|
|
964
|
+
if (arrow.authoredStraight) return [];
|
|
965
|
+
return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
|
|
966
|
+
Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
|
967
|
+
? [{ segmentIndex, start, end }]
|
|
968
|
+
: []
|
|
969
|
+
));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function collectLegendBoxes(fragment) {
|
|
973
|
+
const boxes = [];
|
|
974
|
+
|
|
975
|
+
for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
|
|
976
|
+
const attrs = parseAttrs(match[0]);
|
|
977
|
+
const x = numberAttr(attrs, 'x');
|
|
978
|
+
const y = numberAttr(attrs, 'y');
|
|
979
|
+
const width = numberAttr(attrs, 'width');
|
|
980
|
+
const height = numberAttr(attrs, 'height');
|
|
981
|
+
if ([x, y, width, height].every(Number.isFinite)) {
|
|
982
|
+
boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
987
|
+
const attrs = parseAttrs(match[1]);
|
|
988
|
+
const box = textBox(attrs, stripTags(match[2]).trim());
|
|
989
|
+
if (box) boxes.push(box);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
return boxes;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function collectLegendCollisions(arrows, boxes) {
|
|
996
|
+
const collisions = [];
|
|
997
|
+
for (const arrow of arrows) {
|
|
998
|
+
for (const segment of arrow.segments) {
|
|
999
|
+
for (const box of boxes) {
|
|
1000
|
+
if (segmentIntersectsBox(segment, padBox(box, 2))) {
|
|
1001
|
+
collisions.push({ arrow, box });
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return collisions;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function textBox(attrs, text) {
|
|
1010
|
+
const x = numberAttr(attrs, 'x');
|
|
1011
|
+
const y = numberAttr(attrs, 'y');
|
|
1012
|
+
const fontSize = Number.parseFloat(attrs['font-size'] || '10');
|
|
1013
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
|
|
1014
|
+
const width = estimatedTextWidth(text, fontSize);
|
|
1015
|
+
const anchor = attrs['text-anchor'] || 'start';
|
|
1016
|
+
let x1 = x;
|
|
1017
|
+
if (anchor === 'middle') x1 = x - width / 2;
|
|
1018
|
+
if (anchor === 'end') x1 = x - width;
|
|
1019
|
+
return {
|
|
1020
|
+
x1,
|
|
1021
|
+
y1: y - fontSize,
|
|
1022
|
+
x2: x1 + width,
|
|
1023
|
+
y2: y + fontSize * 0.25,
|
|
1024
|
+
label: text || `text@${x},${y}`,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// A foreign artifact may author a legal non-zero viewBox origin, so containment
|
|
1029
|
+
// needs all four numbers; sizing checks read the trailing pair.
|
|
1030
|
+
function viewBoxRect(svgAttrs) {
|
|
1031
|
+
const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
|
|
1032
|
+
return viewBox.length === 4 ? viewBox : [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function viewBoxSize(svgAttrs) {
|
|
1036
|
+
return viewBoxRect(svgAttrs).slice(2);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function hasAttribute(attrs, name) {
|
|
1040
|
+
return new RegExp('(?:^|\\s)' + name + '(?:\\s*=|\\s|$)', 'i').test(attrs);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function readerContractFromHtml(source) {
|
|
1044
|
+
const markers = [...source.matchAll(/<meta\b[^>]*>/gi)]
|
|
1045
|
+
.map((match) => parseAttrs(match[0]))
|
|
1046
|
+
.filter((attrs) => attrs.name === 'archify-reader-contract');
|
|
1047
|
+
return markers.length === 1 && markers[0].content === DECLARED_WIDE_READER_CONTRACT
|
|
1048
|
+
? DECLARED_WIDE_READER_CONTRACT
|
|
1049
|
+
: null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function collectDesktopReadability(svgAttrs, fragment, contract) {
|
|
1053
|
+
const [viewBoxWidth, viewBoxHeight] = viewBoxSize(svgAttrs);
|
|
1054
|
+
const requestedMinimumTextPx = Number.parseFloat(svgAttrs['data-reader-min-text'] || '');
|
|
1055
|
+
const entries = [];
|
|
1056
|
+
let invalidSemanticText = false;
|
|
1057
|
+
const groups = [];
|
|
1058
|
+
// Keep the complete ancestry, rather than a node-only stack: an edge can
|
|
1059
|
+
// share its context group with its label or nest that group (Sequence).
|
|
1060
|
+
for (const match of fragment.matchAll(/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<text\b([^>]*)>([\s\S]*?)<\/text>|<g\b[^>]*>|<\/g\s*>/gi)) {
|
|
1061
|
+
if (match[0].startsWith('<!')) continue;
|
|
1062
|
+
if (match[1] === undefined) {
|
|
1063
|
+
if (/^<\/g/i.test(match[0])) groups.pop();
|
|
1064
|
+
else if (!/\/\s*>$/.test(match[0])) groups.push(parseAttrs(match[0]));
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
const attrs = parseAttrs(match[1]);
|
|
1068
|
+
if (attrs['data-detail'] === 'fine' || groups.some((group) => group['data-detail'] === 'fine')) continue;
|
|
1069
|
+
const primary = hasAttribute(match[1], 'data-node-label');
|
|
1070
|
+
const boundary = hasAttribute(match[1], 'data-boundary-label');
|
|
1071
|
+
const context = attrs['data-detail'] === 'context'
|
|
1072
|
+
|| groups.some((group) => group['data-detail'] === 'context');
|
|
1073
|
+
const nodeOwner = [...groups].reverse().find((group) => group['data-node-id']);
|
|
1074
|
+
const edgeOwner = [...groups].reverse().find((group) => group['data-edge-from'] && group['data-edge-to']);
|
|
1075
|
+
const owner = primary
|
|
1076
|
+
? { kind: 'node', id: nodeOwner?.['data-node-id'] || null }
|
|
1077
|
+
: boundary ? { kind: 'boundary', id: null }
|
|
1078
|
+
: context && edgeOwner ? {
|
|
1079
|
+
kind: 'edge', id: edgeOwner['data-edge-id'] || null,
|
|
1080
|
+
from: edgeOwner['data-edge-from'], to: edgeOwner['data-edge-to'],
|
|
1081
|
+
}
|
|
1082
|
+
: context && nodeOwner ? { kind: 'node', id: nodeOwner['data-node-id'] }
|
|
1083
|
+
: null;
|
|
1084
|
+
// A context text with neither semantic owner is legend/fine/loose copy.
|
|
1085
|
+
if (!owner) continue;
|
|
1086
|
+
const fontSize = Number.parseFloat(attrs['font-size'] || '');
|
|
1087
|
+
if (!Number.isFinite(fontSize)) {
|
|
1088
|
+
invalidSemanticText = true;
|
|
1089
|
+
continue;
|
|
1090
|
+
}
|
|
1091
|
+
if (fontSize <= 0) invalidSemanticText = true;
|
|
1092
|
+
entries.push({
|
|
1093
|
+
...(owner.kind === 'node' && owner.id ? { nodeId: owner.id } : {}),
|
|
1094
|
+
owner,
|
|
1095
|
+
text: stripTags(match[2]).trim(),
|
|
1096
|
+
detail: primary ? 'primary' : boundary ? 'boundary' : owner.kind === 'edge' ? 'edge' : 'context',
|
|
1097
|
+
sourceFontPx: fontSize,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
const minimumSourceTextPx = entries.length ? Math.min(...entries.map((entry) => entry.sourceFontPx)) : Number.NaN;
|
|
1101
|
+
const eligible = contract === DECLARED_WIDE_READER_CONTRACT
|
|
1102
|
+
&& svgAttrs['data-reader-fit'] === 'intrinsic-height'
|
|
1103
|
+
&& Number.isFinite(requestedMinimumTextPx) && requestedMinimumTextPx > 0
|
|
1104
|
+
&& !invalidSemanticText && entries.length > 0;
|
|
1105
|
+
const declared = eligible ? declaredWideReadabilityBudget({
|
|
1106
|
+
viewBoxWidth,
|
|
1107
|
+
viewBoxHeight,
|
|
1108
|
+
minimumSourceTextPx,
|
|
1109
|
+
requestedMinimumTextPx,
|
|
1110
|
+
}) : null;
|
|
1111
|
+
const availableDiagramWidth = declared?.guaranteedSvgWidth ?? DESKTOP_READER_DIAGRAM_WIDTH;
|
|
1112
|
+
const budgetBasis = declared ? 'recognized-declared-wide' : 'legacy-930';
|
|
1113
|
+
const scale = Number.isFinite(viewBoxWidth) && viewBoxWidth > 0
|
|
1114
|
+
? Math.min(1, availableDiagramWidth / viewBoxWidth) : Number.NaN;
|
|
1115
|
+
const projected = entries.map((entry) => ({
|
|
1116
|
+
...entry,
|
|
1117
|
+
viewBoxWidth,
|
|
1118
|
+
scale,
|
|
1119
|
+
projectedFontPx: projectedNodeTextPx(entry.sourceFontPx, viewBoxWidth, availableDiagramWidth),
|
|
1120
|
+
}));
|
|
1121
|
+
const worst = projected.reduce((current, entry) => (
|
|
1122
|
+
!current || entry.projectedFontPx < current.projectedFontPx ? entry : current
|
|
1123
|
+
), null);
|
|
1124
|
+
const projectedMinimumTextPx = Number.isFinite(worst?.projectedFontPx) ? worst.projectedFontPx : null;
|
|
1125
|
+
const hardFloorMet = Number.isFinite(worst?.projectedFontPx)
|
|
1126
|
+
? worst.projectedFontPx >= MIN_PROJECTED_NODE_TEXT_PX : null;
|
|
1127
|
+
const requestedTargetMet = worst && Number.isFinite(requestedMinimumTextPx)
|
|
1128
|
+
? worst.projectedFontPx >= requestedMinimumTextPx : null;
|
|
1129
|
+
const evidence = {
|
|
1130
|
+
budgetBasis,
|
|
1131
|
+
readerContract: contract,
|
|
1132
|
+
availableDiagramWidth,
|
|
1133
|
+
actualBudgetPx: availableDiagramWidth,
|
|
1134
|
+
...(declared ? {
|
|
1135
|
+
actualReaderWidth: declared.actualReaderWidth,
|
|
1136
|
+
desiredReaderWidth: declared.desiredReaderWidth,
|
|
1137
|
+
viewportCap: declared.viewportCap,
|
|
1138
|
+
limit: declared.limit,
|
|
1139
|
+
} : { limit: 'legacy' }),
|
|
1140
|
+
requestedTargetPx: Number.isFinite(requestedMinimumTextPx) ? requestedMinimumTextPx : null,
|
|
1141
|
+
requestedTargetMet,
|
|
1142
|
+
hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
1143
|
+
hardFloorMet,
|
|
1144
|
+
minimumOwner: worst?.owner || null,
|
|
1145
|
+
minimumSourceTextPx: worst?.sourceFontPx ?? null,
|
|
1146
|
+
minimumProjectedTextPx: projectedMinimumTextPx,
|
|
1147
|
+
semanticTextCount: entries.length,
|
|
1148
|
+
};
|
|
1149
|
+
return { evidence, issue: worst && hardFloorMet === false ? worst : null };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function estimatedTextWidth(text, fontSize) {
|
|
1153
|
+
let units = 0;
|
|
1154
|
+
for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
|
|
1155
|
+
return Math.max(fontSize, units * fontSize);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function pointsFromPath(d) {
|
|
1159
|
+
const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
|
|
1160
|
+
const points = [];
|
|
1161
|
+
let i = 0;
|
|
1162
|
+
let command = '';
|
|
1163
|
+
let current = [0, 0];
|
|
1164
|
+
let start = null;
|
|
1165
|
+
|
|
1166
|
+
while (i < tokens.length) {
|
|
1167
|
+
if (isCommand(tokens[i])) command = tokens[i++];
|
|
1168
|
+
if (!command) break;
|
|
1169
|
+
|
|
1170
|
+
const absolute = command === command.toUpperCase();
|
|
1171
|
+
switch (command.toUpperCase()) {
|
|
1172
|
+
case 'M':
|
|
1173
|
+
case 'L': {
|
|
1174
|
+
while (i + 1 < tokens.length && !isCommand(tokens[i])) {
|
|
1175
|
+
const x = Number.parseFloat(tokens[i++]);
|
|
1176
|
+
const y = Number.parseFloat(tokens[i++]);
|
|
1177
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) break;
|
|
1178
|
+
current = absolute ? [x, y] : [current[0] + x, current[1] + y];
|
|
1179
|
+
points.push(current);
|
|
1180
|
+
if (!start) start = current;
|
|
1181
|
+
}
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
case 'H': {
|
|
1185
|
+
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
1186
|
+
const x = Number.parseFloat(tokens[i++]);
|
|
1187
|
+
if (!Number.isFinite(x)) break;
|
|
1188
|
+
current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
|
|
1189
|
+
points.push(current);
|
|
1190
|
+
}
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
case 'V': {
|
|
1194
|
+
while (i < tokens.length && !isCommand(tokens[i])) {
|
|
1195
|
+
const y = Number.parseFloat(tokens[i++]);
|
|
1196
|
+
if (!Number.isFinite(y)) break;
|
|
1197
|
+
current = absolute ? [current[0], y] : [current[0], current[1] + y];
|
|
1198
|
+
points.push(current);
|
|
1199
|
+
}
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
case 'Q': {
|
|
1203
|
+
while (i + 3 < tokens.length && !isCommand(tokens[i])) {
|
|
1204
|
+
const controlX = Number.parseFloat(tokens[i++]);
|
|
1205
|
+
const controlY = Number.parseFloat(tokens[i++]);
|
|
1206
|
+
const endX = Number.parseFloat(tokens[i++]);
|
|
1207
|
+
const endY = Number.parseFloat(tokens[i++]);
|
|
1208
|
+
if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
|
|
1209
|
+
const control = absolute
|
|
1210
|
+
? [controlX, controlY]
|
|
1211
|
+
: [current[0] + controlX, current[1] + controlY];
|
|
1212
|
+
const end = absolute
|
|
1213
|
+
? [endX, endY]
|
|
1214
|
+
: [current[0] + endX, current[1] + endY];
|
|
1215
|
+
const startPoint = current;
|
|
1216
|
+
for (let step = 1; step <= 8; step += 1) {
|
|
1217
|
+
const amount = step / 8;
|
|
1218
|
+
const remaining = 1 - amount;
|
|
1219
|
+
points.push([
|
|
1220
|
+
remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
|
|
1221
|
+
remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
|
|
1222
|
+
]);
|
|
1223
|
+
}
|
|
1224
|
+
current = end;
|
|
1225
|
+
}
|
|
1226
|
+
break;
|
|
1227
|
+
}
|
|
1228
|
+
case 'Z': {
|
|
1229
|
+
if (start) points.push(start);
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
default:
|
|
1233
|
+
return [];
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
return points.filter(isPoint);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function properSegmentIntersection(a, b, c, d) {
|
|
1241
|
+
const abC = crossProduct(a, b, c);
|
|
1242
|
+
const abD = crossProduct(a, b, d);
|
|
1243
|
+
const cdA = crossProduct(c, d, a);
|
|
1244
|
+
const cdB = crossProduct(c, d, b);
|
|
1245
|
+
const epsilon = 1e-9;
|
|
1246
|
+
const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
|
|
1247
|
+
if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
|
|
1248
|
+
const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
|
1249
|
+
if (Math.abs(denominator) < epsilon) return null;
|
|
1250
|
+
const ab = a[0] * b[1] - a[1] * b[0];
|
|
1251
|
+
const cd = c[0] * d[1] - c[1] * d[0];
|
|
1252
|
+
return [
|
|
1253
|
+
(ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
|
|
1254
|
+
(ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
|
|
1255
|
+
];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function crossProduct(a, b, c) {
|
|
1259
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function segmentIntersectsBox(segment, box) {
|
|
1263
|
+
const { start, end } = segment;
|
|
1264
|
+
if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
|
|
1265
|
+
const edges = [
|
|
1266
|
+
[[box.x1, box.y1], [box.x2, box.y1]],
|
|
1267
|
+
[[box.x2, box.y1], [box.x2, box.y2]],
|
|
1268
|
+
[[box.x2, box.y2], [box.x1, box.y2]],
|
|
1269
|
+
[[box.x1, box.y2], [box.x1, box.y1]],
|
|
1270
|
+
];
|
|
1271
|
+
return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function segmentsIntersect(a, b, c, d) {
|
|
1275
|
+
const o1 = orientation(a, b, c);
|
|
1276
|
+
const o2 = orientation(a, b, d);
|
|
1277
|
+
const o3 = orientation(c, d, a);
|
|
1278
|
+
const o4 = orientation(c, d, b);
|
|
1279
|
+
|
|
1280
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
1281
|
+
if (o1 === 0 && onSegment(a, c, b)) return true;
|
|
1282
|
+
if (o2 === 0 && onSegment(a, d, b)) return true;
|
|
1283
|
+
if (o3 === 0 && onSegment(c, a, d)) return true;
|
|
1284
|
+
if (o4 === 0 && onSegment(c, b, d)) return true;
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function orientation(a, b, c) {
|
|
1289
|
+
const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
|
|
1290
|
+
if (Math.abs(value) < 1e-9) return 0;
|
|
1291
|
+
return value > 0 ? 1 : 2;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function onSegment(a, b, c) {
|
|
1295
|
+
return b[0] <= Math.max(a[0], c[0]) + 1e-9
|
|
1296
|
+
&& b[0] + 1e-9 >= Math.min(a[0], c[0])
|
|
1297
|
+
&& b[1] <= Math.max(a[1], c[1]) + 1e-9
|
|
1298
|
+
&& b[1] + 1e-9 >= Math.min(a[1], c[1]);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function pointInsideBox(point, box) {
|
|
1302
|
+
return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function padBox(box, padding) {
|
|
1306
|
+
return {
|
|
1307
|
+
...box,
|
|
1308
|
+
x1: box.x1 - padding,
|
|
1309
|
+
y1: box.y1 - padding,
|
|
1310
|
+
x2: box.x2 + padding,
|
|
1311
|
+
y2: box.y2 + padding,
|
|
1312
|
+
};
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Only geometry/numeric attributes are scanned: authored prose such as node
|
|
1316
|
+
// tags, <title> text, aria-labels, or data-* attributes may legitimately
|
|
1317
|
+
// mention "NaN" or "Infinity" without any coordinate being non-finite.
|
|
1318
|
+
function collectNonFiniteAttrs(svg) {
|
|
1319
|
+
const details = [];
|
|
1320
|
+
const stack = [];
|
|
1321
|
+
for (const match of svg.matchAll(SVG_TAG_TOKEN)) {
|
|
1322
|
+
if (!match[2]) continue;
|
|
1323
|
+
const element = match[2].toLowerCase();
|
|
1324
|
+
if (match[1]) {
|
|
1325
|
+
const index = stack.map(entry => entry.element).lastIndexOf(element);
|
|
1326
|
+
if (index >= 0) stack.length = index;
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const parent = stack[stack.length - 1];
|
|
1330
|
+
const inSvg = element === 'svg'
|
|
1331
|
+
|| Boolean(parent?.inSvg && !SVG_HTML_INTEGRATION_POINTS.has(parent.element));
|
|
1332
|
+
if (inSvg) {
|
|
1333
|
+
for (const [name, value] of attrEntries(match[0])) {
|
|
1334
|
+
if (!isNumericAttr(element, name) || !NON_FINITE_TOKEN.test(decodeNumericReferences(value))) continue;
|
|
1335
|
+
details.push(`${match[2]} ${name}="${value}"`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
// HTML void elements do not open a context; SVG self-closing tags do not
|
|
1339
|
+
// either. A nested <svg> inside foreignObject restores SVG checking.
|
|
1340
|
+
if (!(inSvg ? /\/\s*>$/.test(match[0]) : HTML_VOID_ELEMENTS.has(element))) {
|
|
1341
|
+
stack.push({ element, inSvg });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return details;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function decodeNumericReferences(value) {
|
|
1348
|
+
// Numeric references can encode every letter of NaN/Infinity/undefined.
|
|
1349
|
+
// Decode once, locally: other checks and diagnostic evidence keep raw values.
|
|
1350
|
+
return value.replace(/&#(?:x([0-9a-f]+)|([0-9]+));?/gi, (_, hex, decimal) => {
|
|
1351
|
+
const point = Number.parseInt(hex || decimal, hex ? 16 : 10);
|
|
1352
|
+
return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
|
|
1353
|
+
? String.fromCodePoint(point) : '\uFFFD';
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function isNumericAttr(elementName, attrName) {
|
|
1358
|
+
const normalizedAttr = attrName.toLowerCase();
|
|
1359
|
+
return NUMERIC_ATTRS.has(normalizedAttr)
|
|
1360
|
+
|| ELEMENT_NUMERIC_ATTRS.get(elementName.toLowerCase())?.has(normalizedAttr);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function attrEntries(tag) {
|
|
1364
|
+
return [...tag.matchAll(HTML_ATTRIBUTE)].map((match) => [
|
|
1365
|
+
match[1],
|
|
1366
|
+
match[2] ?? match[3] ?? match[4],
|
|
1367
|
+
]);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function parseAttrs(tag) {
|
|
1371
|
+
return Object.fromEntries(attrEntries(tag));
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
function numberAttr(attrs, name) {
|
|
1375
|
+
return Number.parseFloat(attrs[name]);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function isCommand(token) {
|
|
1379
|
+
return /^[A-Za-z]$/.test(token);
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function isPoint(point) {
|
|
1383
|
+
return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function stripTags(value) {
|
|
1387
|
+
return value.replace(/<[^>]*>/g, '');
|
|
1388
|
+
}
|