Repository navigation
Expand file tree
/
Copy pathcheck_dot_fit.mjs
More file actions
185 lines (170 loc) · 7.29 KB
/
Copy pathcheck_dot_fit.mjs
File metadata and controls
185 lines (170 loc) · 7.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
#!/usr/bin/env node
// Does every DOT diagram's text still fit the boxes Graphviz drew for it?
//
// node scripts/check_dot_fit.mjs # check every committed .svg
// node scripts/check_dot_fit.mjs --verbose # print every run, not just failures
//
// WHY THIS EXISTS. Graphviz lays out boxes from a width table; the browser
// paints the text with a real font. Those are two different measurements of
// the same string, and nothing inside the build compares them -- so when they
// disagree, the SVG is still well-formed, the build is still green, and the
// only symptom is a label hanging out of its box on the rendered page.
//
// That is not hypothetical. Graphviz has no font machinery at all and falls
// back to Times for any family it does not know, so `fontname="Inter"`
// measures identically to `fontname="NoSuchFontXYZ"` and every box comes out
// ~11% too narrow, leaving labels hanging past their edges on pages that pass
// the full accessibility sweep, because axe does not evaluate SVG <text>
// geometry either. builder/dot-metrics.mjs removes the cause; this proves it
// stays removed.
//
// It runs in check.bat rather than in the build for the same reason the axe
// scan does: it needs a browser, and builder/dot.mjs is deliberately free of
// one.
import { promises as fs } from "node:fs";
import path from "node:path";
import { listDotSources } from "../builder/dot.mjs";
import { withBrowser } from "./lib/browser.mjs";
import { openInterPage } from "./lib/inter-page.mjs";
import { exitOnCrash, parseCli, printHelpAndExit, withUsageError } from "../lib/cli.mjs";
import { DOCS_DIR, REPO_ROOT } from "../lib/repo-paths.mjs";
exitOnCrash();
const USAGE = `usage: node scripts/check_dot_fit.mjs [--verbose] [-h, --help]
Checks that the text of every committed DOT diagram still fits the boxes
Graphviz drew for it, by measuring the text in a browser.
--verbose also print the tolerance under each diagram that fits
-h, --help print this text and exit
Exit codes:
0 every diagram's text fits its boxes, or no diagram was found
1 the text of at least one diagram sits outside its box
2 the gate could not run: a refused command line, no browser, or a crash`;
// A label may sit this far past its box edge before it counts as a failure.
// Kerning is the irreducible part: a per-character table cannot express it,
// and on the site's labels it makes Graphviz over-estimate by up to 2.8% --
// which errs wide, so it does not push text out. This budget is for the
// rounding either side of that, not for a font mismatch, which shows up as
// tens of units rather than ones.
const TOLERANCE = 1.0;
const cli = withUsageError(() =>
parseCli(process.argv.slice(2), {
options: { verbose: { type: "boolean" }, help: { type: "boolean", short: "h" } },
stopAt: ["help"],
}),
);
if (cli.values.help) printHelpAndExit(USAGE);
const verbose = cli.values.verbose === true;
// The committed SVG of every diagram the build renders, found as it finds them.
const svgs = [];
for (const dot of await listDotSources(DOCS_DIR)) {
const svg = dot.replace(/\.dot$/, ".svg");
if (
await fs.stat(svg).then(
() => true,
() => false,
)
)
svgs.push(svg);
}
svgs.sort();
if (svgs.length === 0) {
console.log("check_dot_fit: no DOT diagrams found");
process.exit(0);
}
let failures = 0;
let checked = 0;
await withBrowser(async (browser) => {
const page = await openInterPage(browser, "dot-fit", {
css:
"body{font-family:Inter,system-ui,sans-serif;margin:0}\n" +
"#host{width:1200px}#host svg{width:1200px;height:auto}",
body: '<div id="host"></div>',
});
await page.evaluate(async () => {
await Promise.all([
document.fonts.load("400 12px Inter"),
document.fonts.load("700 12px Inter"),
document.fonts.load("italic 400 12px Inter"),
document.fonts.load("italic 700 12px Inter"),
]);
await document.fonts.ready;
});
for (const svgPath of svgs) {
const rel = path.relative(REPO_ROOT, svgPath).replace(/\\/g, "/");
const svg = await fs.readFile(svgPath, "utf8");
const result = await page.evaluate(
(markup, tol) => {
document.getElementById("host").innerHTML = markup;
const root = document.querySelector("#host svg");
if (!root) return { error: "no <svg> element" };
const runs = [];
// Nodes: the label is centred in its box, so both edges matter and the
// off-centre figure is meaningful.
for (const g of root.querySelectorAll("g.node")) {
const shape = g.querySelector("path, polygon, ellipse, rect");
if (!shape) continue;
const sb = shape.getBBox();
for (const t of g.querySelectorAll("text")) {
const tb = t.getBBox();
runs.push({
txt: t.textContent.trim().slice(0, 44),
over: Math.max(tb.x + tb.width - (sb.x + sb.width), sb.x - tb.x),
skew: tb.x + tb.width / 2 - (sb.x + sb.width / 2),
});
}
}
// Clusters: the label is left-justified along the top edge, so only the
// right edge can be outrun and `skew` would mean nothing. Worth checking
// even though the cluster is sized by its contents -- shorten the nodes
// inside one and the label becomes the widest thing in it.
for (const g of root.querySelectorAll("g.cluster")) {
const shape = g.querySelector("path, polygon, ellipse, rect");
if (!shape) continue;
const sb = shape.getBBox();
for (const t of g.querySelectorAll("text")) {
const tb = t.getBBox();
runs.push({
txt: t.textContent.trim().slice(0, 44),
over: tb.x + tb.width - (sb.x + sb.width),
skew: 0,
});
}
}
if (runs.length === 0) return { error: "no text runs -- did the SVG render?" };
return {
total: runs.length,
bad: runs.filter((r) => r.over > tol).sort((a, b) => b.over - a.over),
maxSkew: runs.reduce((a, r) => (Math.abs(r.skew) > Math.abs(a) ? r.skew : a), 0),
};
},
svg,
TOLERANCE,
);
checked++;
if (result.error) {
console.error(` ERROR ${rel}: ${result.error}`);
failures++;
continue;
}
if (result.bad.length) {
failures++;
console.error(` OVERFLOW ${rel}: ${result.bad.length} of ${result.total} label(s) past the box edge`);
for (const b of result.bad.slice(0, 6)) {
console.error(` +${b.over.toFixed(1)} "${b.txt}"`);
}
if (result.bad.length > 6) console.error(` ... and ${result.bad.length - 6} more`);
} else {
console.log(` ok ${rel} (${result.total} runs, max off-centre ${result.maxSkew.toFixed(1)})`);
if (verbose) console.log(` tolerance ${TOLERANCE}`);
}
}
});
if (failures) {
console.error(
`\ncheck_dot_fit: ${failures} of ${checked} diagram(s) have text outside their boxes.\n` +
"Graphviz sized those boxes with a different font from the one the page paints with.\n" +
"Check that builder/dot-metrics.mjs still installs Inter's widths -- a bump of\n" +
"@hpcc-js/wasm-graphviz is the usual cause -- then re-render with build.bat.",
);
process.exit(1);
}
console.log(`\ncheck_dot_fit: ${checked} diagram(s) clean`);