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