@saluzi/saluzi-edu 0.4.7 → 0.4.8
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/dist/cli.js +51 -60
- package/dist/guide/guide-data.json +1 -1
- package/dist/vendor/archify/LICENSE +22 -0
- package/dist/vendor/archify/SKILL.md +137 -0
- package/dist/vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- package/dist/vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/dist/vendor/archify/assets/template.html +14934 -0
- package/dist/vendor/archify/bin/archify.mjs +2687 -0
- package/dist/vendor/archify/bin/open-artifact.mjs +95 -0
- package/dist/vendor/archify/bin/preview.mjs +764 -0
- package/dist/vendor/archify/bin/visual-check.mjs +1012 -0
- package/dist/vendor/archify/brand-marks/README.md +31 -0
- package/dist/vendor/archify/brand-marks/catalog.json +131 -0
- package/dist/vendor/archify/delta/architecture-delta.mjs +1221 -0
- package/dist/vendor/archify/examples/agent-run.lifecycle.json +60 -0
- package/dist/vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- package/dist/vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- package/dist/vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- package/dist/vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- package/dist/vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- package/dist/vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- package/dist/vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- package/dist/vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- package/dist/vendor/archify/examples/event-stream.dataflow.json +57 -0
- package/dist/vendor/archify/examples/incident-response.workflow.json +64 -0
- package/dist/vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- package/dist/vendor/archify/examples/product-analytics.dataflow.json +76 -0
- package/dist/vendor/archify/examples/production-deployment.architecture.json +71 -0
- package/dist/vendor/archify/examples/release-delivery.workflow.json +62 -0
- package/dist/vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- package/dist/vendor/archify/examples/web-app-rendered.html +15009 -0
- package/dist/vendor/archify/examples/web-app.architecture.json +46 -0
- package/dist/vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- package/dist/vendor/archify/migrations/workflow-v2.mjs +279 -0
- package/dist/vendor/archify/package.json +17 -0
- package/dist/vendor/archify/recipes/scenarios.mjs +391 -0
- package/dist/vendor/archify/references/authoring-contract.md +243 -0
- package/dist/vendor/archify/references/brand-marks.md +65 -0
- package/dist/vendor/archify/references/delivery-contract.md +120 -0
- package/dist/vendor/archify/references/viewer-runtime.md +45 -0
- package/dist/vendor/archify/renderers/architecture/grid.mjs +62 -0
- package/dist/vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- package/dist/vendor/archify/renderers/dataflow/README.md +104 -0
- package/dist/vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- package/dist/vendor/archify/renderers/lifecycle/README.md +115 -0
- package/dist/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- package/dist/vendor/archify/renderers/sequence/README.md +114 -0
- package/dist/vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- package/dist/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- package/dist/vendor/archify/renderers/shared/cli.mjs +218 -0
- package/dist/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- package/dist/vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- package/dist/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/dist/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/dist/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- package/dist/vendor/archify/renderers/shared/geometry.mjs +1423 -0
- package/dist/vendor/archify/renderers/shared/i18n.mjs +595 -0
- package/dist/vendor/archify/renderers/shared/layout-report.mjs +40 -0
- package/dist/vendor/archify/renderers/shared/legend.mjs +217 -0
- package/dist/vendor/archify/renderers/shared/output-path.mjs +340 -0
- package/dist/vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- package/dist/vendor/archify/renderers/shared/repository-location.mjs +58 -0
- package/dist/vendor/archify/renderers/shared/text-fit.mjs +49 -0
- package/dist/vendor/archify/renderers/shared/utils.mjs +232 -0
- package/dist/vendor/archify/renderers/shared/validator.mjs +86 -0
- package/dist/vendor/archify/renderers/workflow/README.md +223 -0
- package/dist/vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- package/dist/vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- package/dist/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/dist/vendor/archify/schemas/README.md +211 -0
- package/dist/vendor/archify/schemas/architecture.schema.json +178 -0
- package/dist/vendor/archify/schemas/common.schema.json +115 -0
- package/dist/vendor/archify/schemas/dataflow.schema.json +243 -0
- package/dist/vendor/archify/schemas/lifecycle.schema.json +266 -0
- package/dist/vendor/archify/schemas/sequence.schema.json +223 -0
- package/dist/vendor/archify/schemas/workflow.schema.json +428 -0
- package/dist/vendor/archify/scripts/check-render-output.mjs +836 -0
- package/dist/vendor/archify/scripts/check-update.mjs +1667 -0
- package/dist/vendor/archify/scripts/render-examples.mjs +26 -0
- package/dist/vendor/archify/scripts/update-contract.mjs +182 -0
- package/dist/vendor/archify/skill-release.json +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
|
|
6
|
+
import {
|
|
7
|
+
DESKTOP_READABILITY_VIEWPORT,
|
|
8
|
+
DESKTOP_READER_DIAGRAM_WIDTH,
|
|
9
|
+
MIN_PROJECTED_NODE_TEXT_PX,
|
|
10
|
+
projectedNodeTextPx,
|
|
11
|
+
} from '../renderers/shared/desktop-readability.mjs';
|
|
12
|
+
|
|
13
|
+
const input = process.argv[2];
|
|
14
|
+
|
|
15
|
+
if (!input || input === '-h' || input === '--help') {
|
|
16
|
+
console.error('Usage: node scripts/check-render-output.mjs <diagram.html>');
|
|
17
|
+
process.exit(input ? 0 : 2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const htmlPath = path.resolve(input);
|
|
21
|
+
let html;
|
|
22
|
+
try {
|
|
23
|
+
html = fs.readFileSync(htmlPath, 'utf8');
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(JSON.stringify({
|
|
26
|
+
ok: false,
|
|
27
|
+
file: htmlPath,
|
|
28
|
+
checks: [{ name: 'file_readable', ok: false, details: [err.message] }],
|
|
29
|
+
}, null, 2));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const checks = [];
|
|
34
|
+
let composition = {
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
profile: 'standard',
|
|
37
|
+
status: 'pass',
|
|
38
|
+
summary: { errors: 0, warnings: 0 },
|
|
39
|
+
metrics: {
|
|
40
|
+
properCrossings: 0,
|
|
41
|
+
ambiguousCorridors: 0,
|
|
42
|
+
containerBorderRuns: 0,
|
|
43
|
+
labelRouteClearanceIssues: 0,
|
|
44
|
+
minLabelRouteClearance: null,
|
|
45
|
+
maxBends: 0,
|
|
46
|
+
routesOverSuggestedBends: 0,
|
|
47
|
+
maxStretch: null,
|
|
48
|
+
routesOverSuggestedStretch: 0,
|
|
49
|
+
minSegmentPx: null,
|
|
50
|
+
minInteriorSegmentPx: null,
|
|
51
|
+
shortSegmentCount: 0,
|
|
52
|
+
shortEndpointSegmentCount: 0,
|
|
53
|
+
shortInteriorSegmentCount: 0,
|
|
54
|
+
microSegmentCount: 0,
|
|
55
|
+
desktopReadabilityIssues: 0,
|
|
56
|
+
minProjectedNodeTextPx: null,
|
|
57
|
+
},
|
|
58
|
+
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
|
59
|
+
issues: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function addCheck(name, ok, details = []) {
|
|
63
|
+
checks.push({ name, ok, details });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const svgMatches = [...html.matchAll(/<svg\b[\s\S]*?<\/svg>/gi)];
|
|
67
|
+
addCheck('single_svg', svgMatches.length === 1, [`found ${svgMatches.length} <svg> block(s)`]);
|
|
68
|
+
|
|
69
|
+
if (svgMatches.length === 1) {
|
|
70
|
+
const svg = svgMatches[0][0];
|
|
71
|
+
const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
|
|
72
|
+
const svgAttrs = parseAttrs(svgRoot);
|
|
73
|
+
const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
|
|
74
|
+
const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
|
|
75
|
+
addCheck('finite_svg', !/\b(?:NaN|undefined|Infinity|-Infinity)\b/.test(svg));
|
|
76
|
+
const legendStart = svg.indexOf('<!-- Legend -->');
|
|
77
|
+
const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
|
|
78
|
+
const desktopReadabilityIssue = collectDesktopReadability(svgAttrs, beforeLegend);
|
|
79
|
+
const arrows = collectArrows(beforeLegend);
|
|
80
|
+
const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
|
|
81
|
+
addCheck(
|
|
82
|
+
'orthogonal_arrows',
|
|
83
|
+
diagonal.length === 0,
|
|
84
|
+
diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
|
|
85
|
+
);
|
|
86
|
+
const relationshipCrossings = collectRelationshipCrossings(arrows);
|
|
87
|
+
const compositionFrames = collectCompositionFrames(beforeLegend);
|
|
88
|
+
const containerBorderRuns = collectBorderRuns({
|
|
89
|
+
routedRelations: arrows
|
|
90
|
+
.filter((arrow) => arrow.from && arrow.to && arrow.borderSegments.length)
|
|
91
|
+
.map((arrow) => ({
|
|
92
|
+
relation: arrow,
|
|
93
|
+
relationIndex: arrow.index,
|
|
94
|
+
segments: arrow.borderSegments,
|
|
95
|
+
})),
|
|
96
|
+
frames: compositionFrames,
|
|
97
|
+
});
|
|
98
|
+
const routedRelationships = arrows
|
|
99
|
+
.filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
|
|
100
|
+
.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
|
|
101
|
+
const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
|
|
102
|
+
const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
|
|
103
|
+
const ambiguousCorridors = collectAmbiguousCorridors({ routedRelations: routedRelationships });
|
|
104
|
+
const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
|
|
105
|
+
const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
|
|
106
|
+
const labelRouteMeasurements = collectLabelRouteClearance({
|
|
107
|
+
labels: relationshipLabels,
|
|
108
|
+
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
109
|
+
threshold: Number.MAX_VALUE,
|
|
110
|
+
});
|
|
111
|
+
const labelRouteClearance = collectLabelRouteClearance({
|
|
112
|
+
labels: relationshipLabels,
|
|
113
|
+
routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
|
|
114
|
+
threshold: labelClearanceThreshold,
|
|
115
|
+
});
|
|
116
|
+
const crossingIsError = qualityProfile === 'showcase';
|
|
117
|
+
const corridorIsError = qualityProfile === 'showcase';
|
|
118
|
+
const rhythmIsError = qualityProfile === 'showcase';
|
|
119
|
+
const labelClearanceIsError = qualityProfile === 'showcase';
|
|
120
|
+
const desktopReadabilityIsError = qualityProfile === 'showcase';
|
|
121
|
+
const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
|
|
122
|
+
+ (crossingIsError ? relationshipCrossings.length : 0)
|
|
123
|
+
+ (corridorIsError ? ambiguousCorridors.length : 0)
|
|
124
|
+
+ (labelClearanceIsError ? labelRouteClearance.length : 0)
|
|
125
|
+
+ (rhythmIsError ? routeRhythmIssues.length : 0)
|
|
126
|
+
+ (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0);
|
|
127
|
+
const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
|
|
128
|
+
+ (crossingIsError ? 0 : relationshipCrossings.length)
|
|
129
|
+
+ (corridorIsError ? 0 : ambiguousCorridors.length)
|
|
130
|
+
+ (labelClearanceIsError ? 0 : labelRouteClearance.length)
|
|
131
|
+
+ (rhythmIsError ? 0 : routeRhythmIssues.length)
|
|
132
|
+
+ (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1);
|
|
133
|
+
composition = {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
profile: qualityProfile,
|
|
136
|
+
status: compositionErrors ? 'fail' : 'pass',
|
|
137
|
+
summary: {
|
|
138
|
+
errors: compositionErrors,
|
|
139
|
+
warnings: compositionWarnings,
|
|
140
|
+
},
|
|
141
|
+
metrics: {
|
|
142
|
+
properCrossings: relationshipCrossings.length,
|
|
143
|
+
ambiguousCorridors: ambiguousCorridors.length,
|
|
144
|
+
containerBorderRuns: containerBorderRuns.length,
|
|
145
|
+
labelRouteClearanceIssues: labelRouteClearance.length,
|
|
146
|
+
minLabelRouteClearance: labelRouteMeasurements.length
|
|
147
|
+
? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10
|
|
148
|
+
: null,
|
|
149
|
+
desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
|
|
150
|
+
minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null,
|
|
151
|
+
...roundedRouteMetrics(routeMetrics),
|
|
152
|
+
},
|
|
153
|
+
suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
|
|
154
|
+
issues: [
|
|
155
|
+
...containerBorderRuns.map((hit) => ({
|
|
156
|
+
severity: qualityGatesEnforced ? 'error' : 'warning',
|
|
157
|
+
code: 'composition/container-border-run',
|
|
158
|
+
relationship: relationshipRecord(hit.relation),
|
|
159
|
+
frame: frameRecord(hit.frame),
|
|
160
|
+
side: hit.side,
|
|
161
|
+
segmentIndex: hit.segmentIndex,
|
|
162
|
+
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
|
163
|
+
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
|
164
|
+
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
|
165
|
+
})),
|
|
166
|
+
...labelRouteClearance.map((hit) => ({
|
|
167
|
+
severity: labelClearanceIsError ? 'error' : 'warning',
|
|
168
|
+
code: 'composition/label-route-clearance',
|
|
169
|
+
label: hit.label?.label || hit.labelRelation?.label || '',
|
|
170
|
+
labelRelationship: relationshipRecord(hit.labelRelation),
|
|
171
|
+
otherRelationship: relationshipRecord(hit.otherRelation),
|
|
172
|
+
segmentIndex: hit.segmentIndex,
|
|
173
|
+
labelRect: roundedRect(hit.rect),
|
|
174
|
+
clearance: Math.round(hit.clearance * 10) / 10,
|
|
175
|
+
intersectionLength: Math.round((hit.intersectionLength || 0) * 10) / 10,
|
|
176
|
+
threshold: hit.threshold,
|
|
177
|
+
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
|
178
|
+
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
|
179
|
+
})),
|
|
180
|
+
...relationshipCrossings.map((hit) => ({
|
|
181
|
+
severity: crossingIsError ? 'error' : 'warning',
|
|
182
|
+
code: 'composition/proper-crossing',
|
|
183
|
+
relationship: relationshipRecord(hit.left),
|
|
184
|
+
otherRelationship: relationshipRecord(hit.right),
|
|
185
|
+
point: hit.point.map((value) => Math.round(value * 10) / 10),
|
|
186
|
+
})),
|
|
187
|
+
...ambiguousCorridors.map((hit) => ({
|
|
188
|
+
severity: corridorIsError ? 'error' : 'warning',
|
|
189
|
+
code: 'composition/ambiguous-corridor',
|
|
190
|
+
relationship: relationshipRecord(hit.left.relation),
|
|
191
|
+
otherRelationship: relationshipRecord(hit.right.relation),
|
|
192
|
+
segmentIndex: hit.leftSegment,
|
|
193
|
+
otherSegmentIndex: hit.rightSegment,
|
|
194
|
+
overlapLength: Math.round(hit.overlapLength * 10) / 10,
|
|
195
|
+
from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
|
|
196
|
+
to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
|
|
197
|
+
})),
|
|
198
|
+
...routeRhythmIssues.map((hit) => ({
|
|
199
|
+
severity: rhythmIsError ? 'error' : 'warning',
|
|
200
|
+
code: hit.code,
|
|
201
|
+
relationship: relationshipRecord(hit.relation),
|
|
202
|
+
segmentIndex: hit.segmentIndex,
|
|
203
|
+
position: hit.position,
|
|
204
|
+
length: Math.round(hit.length * 10) / 10,
|
|
205
|
+
from: hit.start.map((value) => Math.round(value * 10) / 10),
|
|
206
|
+
to: hit.end.map((value) => Math.round(value * 10) / 10),
|
|
207
|
+
})),
|
|
208
|
+
...(desktopReadabilityIssue ? [{
|
|
209
|
+
severity: desktopReadabilityIsError ? 'error' : 'warning',
|
|
210
|
+
code: 'composition/desktop-readability',
|
|
211
|
+
viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
|
|
212
|
+
viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
|
|
213
|
+
availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH,
|
|
214
|
+
viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
|
|
215
|
+
scale: desktopReadabilityIssue.scale,
|
|
216
|
+
text: desktopReadabilityIssue.text,
|
|
217
|
+
detail: desktopReadabilityIssue.detail,
|
|
218
|
+
sourceFontPx: desktopReadabilityIssue.sourceFontPx,
|
|
219
|
+
projectedFontPx: desktopReadabilityIssue.projectedFontPx,
|
|
220
|
+
minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
|
|
221
|
+
}] : []),
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
addCheck(
|
|
225
|
+
'label_route_clearance',
|
|
226
|
+
!labelClearanceIsError || labelRouteClearance.length === 0,
|
|
227
|
+
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) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
|
|
229
|
+
)),
|
|
230
|
+
);
|
|
231
|
+
addCheck(
|
|
232
|
+
'relationship_crossings',
|
|
233
|
+
!crossingIsError || relationshipCrossings.length === 0,
|
|
234
|
+
relationshipCrossings.map((hit) => (
|
|
235
|
+
`[composition/proper-crossing] ${qualityProfile} ${relationshipName(hit.left)} crosses ${relationshipName(hit.right)} at [${formatPoint(hit.point)}]`
|
|
236
|
+
)),
|
|
237
|
+
);
|
|
238
|
+
addCheck(
|
|
239
|
+
'relationship_corridors',
|
|
240
|
+
!corridorIsError || ambiguousCorridors.length === 0,
|
|
241
|
+
ambiguousCorridors.map((hit) => (
|
|
242
|
+
`[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
|
+
)),
|
|
244
|
+
);
|
|
245
|
+
addCheck(
|
|
246
|
+
'container_border_runs',
|
|
247
|
+
!qualityGatesEnforced || containerBorderRuns.length === 0,
|
|
248
|
+
containerBorderRuns.map((hit) => (
|
|
249
|
+
`[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)}]`
|
|
250
|
+
)),
|
|
251
|
+
);
|
|
252
|
+
addCheck(
|
|
253
|
+
'route_rhythm',
|
|
254
|
+
!rhythmIsError || routeRhythmIssues.length === 0,
|
|
255
|
+
routeRhythmIssues.map((hit) => (
|
|
256
|
+
`[${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)}]`
|
|
257
|
+
)),
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
if (legendStart >= 0) {
|
|
261
|
+
const legendFragment = svg.slice(legendStart);
|
|
262
|
+
const legendBoxes = collectLegendBoxes(legendFragment);
|
|
263
|
+
const collisions = collectLegendCollisions(arrows, legendBoxes);
|
|
264
|
+
addCheck(
|
|
265
|
+
'legend_clearance',
|
|
266
|
+
collisions.length === 0,
|
|
267
|
+
collisions.map((hit) => `${hit.arrow.kind} ${hit.arrow.index} crosses legend ${hit.box.label}`),
|
|
268
|
+
);
|
|
269
|
+
} else {
|
|
270
|
+
addCheck('legend_clearance', true, ['no legend marker found']);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
|
|
275
|
+
console.log(JSON.stringify({ ok, file: htmlPath, checks, composition }, null, 2));
|
|
276
|
+
// Let pending stdout writes drain: large receipts are asynchronous when piped.
|
|
277
|
+
process.exitCode = ok ? 0 : 1;
|
|
278
|
+
|
|
279
|
+
function collectArrows(fragment) {
|
|
280
|
+
const arrows = [];
|
|
281
|
+
let index = 0;
|
|
282
|
+
|
|
283
|
+
for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
|
|
284
|
+
const raw = tag[0];
|
|
285
|
+
if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
|
|
286
|
+
if (!/\bmarker-end=/.test(raw)) continue;
|
|
287
|
+
const attrs = parseAttrs(raw);
|
|
288
|
+
const segments = tag[1].toLowerCase() === 'line'
|
|
289
|
+
? lineSegments(attrs)
|
|
290
|
+
: pathSegments(attrs.d || '');
|
|
291
|
+
const borderSegments = tag[1].toLowerCase() === 'line'
|
|
292
|
+
? segments
|
|
293
|
+
: straightPathSegments(attrs.d || '');
|
|
294
|
+
arrows.push({
|
|
295
|
+
kind: tag[1].toLowerCase(),
|
|
296
|
+
index: index += 1,
|
|
297
|
+
raw,
|
|
298
|
+
segments,
|
|
299
|
+
borderSegments,
|
|
300
|
+
routePoints: parseRoutePoints(attrs['data-composition-points']) || (
|
|
301
|
+
borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
|
|
302
|
+
),
|
|
303
|
+
from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
|
|
304
|
+
to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
|
|
305
|
+
id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
|
|
306
|
+
key: attrs['data-edge-key'],
|
|
307
|
+
label: attrs['data-edge-label'],
|
|
308
|
+
offset: tag.index,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return arrows;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function collectRelationshipLabelMasks(fragment, arrows) {
|
|
316
|
+
const labels = [];
|
|
317
|
+
for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
|
|
318
|
+
const group = match[0];
|
|
319
|
+
const groupAttrs = parseAttrs(group.match(/<g\b[^>]*>/i)?.[0] || '');
|
|
320
|
+
const rectTag = [...group.matchAll(/<rect\b[^>]*>/gi)]
|
|
321
|
+
.map((item) => item[0])
|
|
322
|
+
.find((tag) => /\bclass="[^"]*\bc-mask\b/.test(tag));
|
|
323
|
+
if (!rectTag) continue;
|
|
324
|
+
const attrs = parseAttrs(rectTag);
|
|
325
|
+
const rect = {
|
|
326
|
+
x: numberAttr(attrs, 'x'),
|
|
327
|
+
y: numberAttr(attrs, 'y'),
|
|
328
|
+
width: numberAttr(attrs, 'width'),
|
|
329
|
+
height: numberAttr(attrs, 'height'),
|
|
330
|
+
};
|
|
331
|
+
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) continue;
|
|
332
|
+
const groupStart = match.index;
|
|
333
|
+
const groupEnd = groupStart + group.length;
|
|
334
|
+
const containedOwner = arrows.find((arrow) => (
|
|
335
|
+
arrow.offset > groupStart
|
|
336
|
+
&& arrow.offset < groupEnd
|
|
337
|
+
&& arrow.from === groupAttrs['data-edge-from']
|
|
338
|
+
&& arrow.to === groupAttrs['data-edge-to']
|
|
339
|
+
&& (!groupAttrs['data-edge-id'] || !arrow.id || arrow.id === groupAttrs['data-edge-id'])
|
|
340
|
+
));
|
|
341
|
+
const owner = arrows.find((arrow) => (
|
|
342
|
+
groupAttrs['data-edge-key'] !== undefined && arrow.key === groupAttrs['data-edge-key']
|
|
343
|
+
)) || containedOwner || arrows.find((arrow) => (
|
|
344
|
+
arrow.id === groupAttrs['data-edge-id']
|
|
345
|
+
&& arrow.from === groupAttrs['data-edge-from']
|
|
346
|
+
&& arrow.to === groupAttrs['data-edge-to']
|
|
347
|
+
));
|
|
348
|
+
if (!owner) continue;
|
|
349
|
+
if (owner.key === undefined && groupAttrs['data-edge-key'] !== undefined) owner.key = groupAttrs['data-edge-key'];
|
|
350
|
+
if (!owner.id && groupAttrs['data-edge-id']) owner.id = groupAttrs['data-edge-id'];
|
|
351
|
+
if (!owner.label && groupAttrs['data-edge-label']) owner.label = groupAttrs['data-edge-label'];
|
|
352
|
+
labels.push({
|
|
353
|
+
relation: owner,
|
|
354
|
+
relationIndex: owner.index,
|
|
355
|
+
label: groupAttrs['data-edge-label'] || '',
|
|
356
|
+
rect,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return labels;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function roundedRect(rect) {
|
|
363
|
+
return Object.fromEntries(Object.entries(rect).map(([key, value]) => [key, Math.round(value * 10) / 10]));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function roundedRouteMetrics(metrics) {
|
|
367
|
+
return {
|
|
368
|
+
...metrics,
|
|
369
|
+
maxStretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
|
|
370
|
+
minSegmentPx: metrics.minSegmentPx == null ? null : Math.round(metrics.minSegmentPx * 10) / 10,
|
|
371
|
+
minInteriorSegmentPx: metrics.minInteriorSegmentPx == null ? null : Math.round(metrics.minInteriorSegmentPx * 10) / 10,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseRoutePoints(value) {
|
|
376
|
+
if (!value) return null;
|
|
377
|
+
const points = value.split(';').map((pair) => pair.split(',').map(Number));
|
|
378
|
+
return points.length >= 2 && points.every(isPoint) ? points : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function collectRelationshipCrossings(arrows) {
|
|
382
|
+
const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length);
|
|
383
|
+
const crossings = [];
|
|
384
|
+
for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
|
|
385
|
+
const left = relationships[leftIndex];
|
|
386
|
+
for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
|
|
387
|
+
const right = relationships[rightIndex];
|
|
388
|
+
if ([left.from, left.to].some((id) => id === right.from || id === right.to)) continue;
|
|
389
|
+
let point = null;
|
|
390
|
+
for (const leftSegment of left.segments) {
|
|
391
|
+
for (const rightSegment of right.segments) {
|
|
392
|
+
point = properSegmentIntersection(leftSegment.start, leftSegment.end, rightSegment.start, rightSegment.end);
|
|
393
|
+
if (point) break;
|
|
394
|
+
}
|
|
395
|
+
if (point) break;
|
|
396
|
+
}
|
|
397
|
+
if (point) crossings.push({ left, right, point });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return crossings;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function relationshipName(arrow) {
|
|
404
|
+
return arrow.id
|
|
405
|
+
? `relationship id "${arrow.id}" ("${arrow.from}" -> "${arrow.to}")`
|
|
406
|
+
: `relationship "${arrow.from}" -> "${arrow.to}"`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function relationshipRecord(arrow) {
|
|
410
|
+
const stableIndex = Number(arrow.key);
|
|
411
|
+
return {
|
|
412
|
+
id: arrow.id,
|
|
413
|
+
from: arrow.from,
|
|
414
|
+
to: arrow.to,
|
|
415
|
+
label: arrow.label || '',
|
|
416
|
+
collectionIndex: Number.isInteger(stableIndex) && stableIndex >= 0 ? stableIndex : arrow.index - 1,
|
|
417
|
+
artifactIndex: arrow.index,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function collectCompositionFrames(fragment) {
|
|
422
|
+
const frames = [];
|
|
423
|
+
for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
|
|
424
|
+
const attrs = parseAttrs(match[0]);
|
|
425
|
+
const kind = attrs['data-composition-frame-kind'];
|
|
426
|
+
if (!kind) continue;
|
|
427
|
+
const identity = attrs['data-composition-frame-id'] || frames.length;
|
|
428
|
+
if (match[1].toLowerCase() === 'rect') {
|
|
429
|
+
const frame = {
|
|
430
|
+
kind,
|
|
431
|
+
id: identity,
|
|
432
|
+
x: numberAttr(attrs, 'x'),
|
|
433
|
+
y: numberAttr(attrs, 'y'),
|
|
434
|
+
width: numberAttr(attrs, 'width'),
|
|
435
|
+
height: numberAttr(attrs, 'height'),
|
|
436
|
+
radius: numberAttr(attrs, 'rx') || 0,
|
|
437
|
+
};
|
|
438
|
+
if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
const segments = match[1].toLowerCase() === 'line'
|
|
442
|
+
? lineSegments(attrs)
|
|
443
|
+
: pathSegments(attrs.d || '');
|
|
444
|
+
for (const [segmentIndex, segment] of segments.entries()) {
|
|
445
|
+
frames.push({
|
|
446
|
+
kind,
|
|
447
|
+
id: segments.length > 1 ? `${identity}:${segmentIndex}` : identity,
|
|
448
|
+
shape: 'line',
|
|
449
|
+
start: segment.start,
|
|
450
|
+
end: segment.end,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return frames;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function frameName(frame) {
|
|
458
|
+
return `${frame.kind || 'frame'} "${frame.id}"`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function frameRecord(frame) {
|
|
462
|
+
return { kind: frame.kind, id: frame.id };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function formatPoint(point) {
|
|
466
|
+
return point.map((value) => Math.round(value * 10) / 10).join(', ');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function lineSegments(attrs) {
|
|
470
|
+
const start = [numberAttr(attrs, 'x1'), numberAttr(attrs, 'y1')];
|
|
471
|
+
const end = [numberAttr(attrs, 'x2'), numberAttr(attrs, 'y2')];
|
|
472
|
+
if (!isPoint(start) || !isPoint(end)) return [];
|
|
473
|
+
return [{ start, end }];
|
|
474
|
+
}
|
|
475
|
+
|
|
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
|
+
function diagonalStraightSegments(arrow) {
|
|
559
|
+
return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
|
|
560
|
+
Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
|
|
561
|
+
? [{ segmentIndex, start, end }]
|
|
562
|
+
: []
|
|
563
|
+
));
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function collectLegendBoxes(fragment) {
|
|
567
|
+
const boxes = [];
|
|
568
|
+
|
|
569
|
+
for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
|
|
570
|
+
const attrs = parseAttrs(match[0]);
|
|
571
|
+
const x = numberAttr(attrs, 'x');
|
|
572
|
+
const y = numberAttr(attrs, 'y');
|
|
573
|
+
const width = numberAttr(attrs, 'width');
|
|
574
|
+
const height = numberAttr(attrs, 'height');
|
|
575
|
+
if ([x, y, width, height].every(Number.isFinite)) {
|
|
576
|
+
boxes.push({ x1: x, y1: y, x2: x + width, y2: y + height, label: `rect@${x},${y}` });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
581
|
+
const attrs = parseAttrs(match[1]);
|
|
582
|
+
const box = textBox(attrs, stripTags(match[2]).trim());
|
|
583
|
+
if (box) boxes.push(box);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return boxes;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function collectLegendCollisions(arrows, boxes) {
|
|
590
|
+
const collisions = [];
|
|
591
|
+
for (const arrow of arrows) {
|
|
592
|
+
for (const segment of arrow.segments) {
|
|
593
|
+
for (const box of boxes) {
|
|
594
|
+
if (segmentIntersectsBox(segment, padBox(box, 2))) {
|
|
595
|
+
collisions.push({ arrow, box });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return collisions;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function textBox(attrs, text) {
|
|
604
|
+
const x = numberAttr(attrs, 'x');
|
|
605
|
+
const y = numberAttr(attrs, 'y');
|
|
606
|
+
const fontSize = Number.parseFloat(attrs['font-size'] || '10');
|
|
607
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
|
|
608
|
+
const width = estimatedTextWidth(text, fontSize);
|
|
609
|
+
const anchor = attrs['text-anchor'] || 'start';
|
|
610
|
+
let x1 = x;
|
|
611
|
+
if (anchor === 'middle') x1 = x - width / 2;
|
|
612
|
+
if (anchor === 'end') x1 = x - width;
|
|
613
|
+
return {
|
|
614
|
+
x1,
|
|
615
|
+
y1: y - fontSize,
|
|
616
|
+
x2: x1 + width,
|
|
617
|
+
y2: y + fontSize * 0.25,
|
|
618
|
+
label: text || `text@${x},${y}`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function collectDesktopReadability(svgAttrs, fragment) {
|
|
623
|
+
const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
|
|
624
|
+
const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN;
|
|
625
|
+
if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null;
|
|
626
|
+
const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth);
|
|
627
|
+
let worst = null;
|
|
628
|
+
for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
|
|
629
|
+
const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]);
|
|
630
|
+
const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]);
|
|
631
|
+
const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]);
|
|
632
|
+
if (!primary && !boundary && !context) continue;
|
|
633
|
+
const attrs = parseAttrs(match[1]);
|
|
634
|
+
const fontSize = Number.parseFloat(attrs['font-size'] || '');
|
|
635
|
+
if (!Number.isFinite(fontSize)) continue;
|
|
636
|
+
const projected = projectedNodeTextPx(fontSize, viewBoxWidth);
|
|
637
|
+
if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue;
|
|
638
|
+
const candidate = {
|
|
639
|
+
viewBoxWidth,
|
|
640
|
+
scale,
|
|
641
|
+
text: stripTags(match[2]).trim(),
|
|
642
|
+
detail: primary
|
|
643
|
+
? 'primary'
|
|
644
|
+
: boundary ? 'boundary' : 'context',
|
|
645
|
+
sourceFontPx: fontSize,
|
|
646
|
+
projectedFontPx: projected,
|
|
647
|
+
};
|
|
648
|
+
if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
|
|
649
|
+
}
|
|
650
|
+
return worst;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function estimatedTextWidth(text, fontSize) {
|
|
654
|
+
let units = 0;
|
|
655
|
+
for (const char of text) units += char.charCodeAt(0) > 255 ? 1.8 : 0.62;
|
|
656
|
+
return Math.max(fontSize, units * fontSize);
|
|
657
|
+
}
|
|
658
|
+
|
|
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
|
+
function properSegmentIntersection(a, b, c, d) {
|
|
742
|
+
const abC = crossProduct(a, b, c);
|
|
743
|
+
const abD = crossProduct(a, b, d);
|
|
744
|
+
const cdA = crossProduct(c, d, a);
|
|
745
|
+
const cdB = crossProduct(c, d, b);
|
|
746
|
+
const epsilon = 1e-9;
|
|
747
|
+
const opposite = (left, right) => (left > epsilon && right < -epsilon) || (left < -epsilon && right > epsilon);
|
|
748
|
+
if (!opposite(abC, abD) || !opposite(cdA, cdB)) return null;
|
|
749
|
+
const denominator = (a[0] - b[0]) * (c[1] - d[1]) - (a[1] - b[1]) * (c[0] - d[0]);
|
|
750
|
+
if (Math.abs(denominator) < epsilon) return null;
|
|
751
|
+
const ab = a[0] * b[1] - a[1] * b[0];
|
|
752
|
+
const cd = c[0] * d[1] - c[1] * d[0];
|
|
753
|
+
return [
|
|
754
|
+
(ab * (c[0] - d[0]) - (a[0] - b[0]) * cd) / denominator,
|
|
755
|
+
(ab * (c[1] - d[1]) - (a[1] - b[1]) * cd) / denominator,
|
|
756
|
+
];
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function crossProduct(a, b, c) {
|
|
760
|
+
return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function segmentIntersectsBox(segment, box) {
|
|
764
|
+
const { start, end } = segment;
|
|
765
|
+
if (pointInsideBox(start, box) || pointInsideBox(end, box)) return true;
|
|
766
|
+
const edges = [
|
|
767
|
+
[[box.x1, box.y1], [box.x2, box.y1]],
|
|
768
|
+
[[box.x2, box.y1], [box.x2, box.y2]],
|
|
769
|
+
[[box.x2, box.y2], [box.x1, box.y2]],
|
|
770
|
+
[[box.x1, box.y2], [box.x1, box.y1]],
|
|
771
|
+
];
|
|
772
|
+
return edges.some(([a, b]) => segmentsIntersect(start, end, a, b));
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function segmentsIntersect(a, b, c, d) {
|
|
776
|
+
const o1 = orientation(a, b, c);
|
|
777
|
+
const o2 = orientation(a, b, d);
|
|
778
|
+
const o3 = orientation(c, d, a);
|
|
779
|
+
const o4 = orientation(c, d, b);
|
|
780
|
+
|
|
781
|
+
if (o1 !== o2 && o3 !== o4) return true;
|
|
782
|
+
if (o1 === 0 && onSegment(a, c, b)) return true;
|
|
783
|
+
if (o2 === 0 && onSegment(a, d, b)) return true;
|
|
784
|
+
if (o3 === 0 && onSegment(c, a, d)) return true;
|
|
785
|
+
if (o4 === 0 && onSegment(c, b, d)) return true;
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function orientation(a, b, c) {
|
|
790
|
+
const value = (b[1] - a[1]) * (c[0] - b[0]) - (b[0] - a[0]) * (c[1] - b[1]);
|
|
791
|
+
if (Math.abs(value) < 1e-9) return 0;
|
|
792
|
+
return value > 0 ? 1 : 2;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function onSegment(a, b, c) {
|
|
796
|
+
return b[0] <= Math.max(a[0], c[0]) + 1e-9
|
|
797
|
+
&& b[0] + 1e-9 >= Math.min(a[0], c[0])
|
|
798
|
+
&& b[1] <= Math.max(a[1], c[1]) + 1e-9
|
|
799
|
+
&& b[1] + 1e-9 >= Math.min(a[1], c[1]);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pointInsideBox(point, box) {
|
|
803
|
+
return point[0] >= box.x1 && point[0] <= box.x2 && point[1] >= box.y1 && point[1] <= box.y2;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function padBox(box, padding) {
|
|
807
|
+
return {
|
|
808
|
+
...box,
|
|
809
|
+
x1: box.x1 - padding,
|
|
810
|
+
y1: box.y1 - padding,
|
|
811
|
+
x2: box.x2 + padding,
|
|
812
|
+
y2: box.y2 + padding,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function parseAttrs(tag) {
|
|
817
|
+
const attrs = {};
|
|
818
|
+
for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
|
|
819
|
+
return attrs;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function numberAttr(attrs, name) {
|
|
823
|
+
return Number.parseFloat(attrs[name]);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function isCommand(token) {
|
|
827
|
+
return /^[A-Za-z]$/.test(token);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function isPoint(point) {
|
|
831
|
+
return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function stripTags(value) {
|
|
835
|
+
return value.replace(/<[^>]*>/g, '');
|
|
836
|
+
}
|