@crewhaus/tool-media 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -0
- package/dist/fixtures.d.ts +81 -0
- package/dist/fixtures.js +385 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +1132 -0
- package/dist/lib/barcode.d.ts +43 -0
- package/dist/lib/barcode.js +310 -0
- package/dist/lib/bytes.d.ts +45 -0
- package/dist/lib/bytes.js +162 -0
- package/dist/lib/chart.d.ts +32 -0
- package/dist/lib/chart.js +290 -0
- package/dist/lib/color.d.ts +53 -0
- package/dist/lib/color.js +267 -0
- package/dist/lib/diagram.d.ts +48 -0
- package/dist/lib/diagram.js +256 -0
- package/dist/lib/ffprobe.d.ts +36 -0
- package/dist/lib/ffprobe.js +154 -0
- package/dist/lib/identify.d.ts +39 -0
- package/dist/lib/identify.js +519 -0
- package/dist/lib/imageops.d.ts +86 -0
- package/dist/lib/imageops.js +236 -0
- package/dist/lib/jpeg.d.ts +81 -0
- package/dist/lib/jpeg.js +388 -0
- package/dist/lib/png.d.ts +59 -0
- package/dist/lib/png.js +466 -0
- package/dist/lib/qr.d.ts +70 -0
- package/dist/lib/qr.js +544 -0
- package/dist/lib/raster.d.ts +18 -0
- package/dist/lib/raster.js +82 -0
- package/dist/lib/sparkline.d.ts +21 -0
- package/dist/lib/sparkline.js +93 -0
- package/dist/lib/subtitle.d.ts +62 -0
- package/dist/lib/subtitle.js +233 -0
- package/dist/lib/svg.d.ts +76 -0
- package/dist/lib/svg.js +168 -0
- package/dist/paths.d.ts +53 -0
- package/dist/paths.js +161 -0
- package/dist/proc.d.ts +39 -0
- package/dist/proc.js +158 -0
- package/package.json +45 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bar, line, scatter and pie charts, laid out and emitted as SVG.
|
|
3
|
+
*
|
|
4
|
+
* The layout is arithmetic on the data plus a handful of fixed constants —
|
|
5
|
+
* no measurement, no iteration to convergence, no randomness — so the same
|
|
6
|
+
* spec renders byte-identical SVG on every machine. The one approximation
|
|
7
|
+
* is label width (see `estimateTextWidth`), which is deliberately an
|
|
8
|
+
* estimate so it cannot vary with installed fonts.
|
|
9
|
+
*/
|
|
10
|
+
import { MediaFormatError } from "./bytes";
|
|
11
|
+
import { FONT_STACK, circle, escapeXml, estimateTextWidth, formatValue, line, niceScale, num, paletteColor, polyline, rect, svgDocument, text, } from "./svg";
|
|
12
|
+
const AXIS = "#8a919b";
|
|
13
|
+
const GRID = "#e2e5ea";
|
|
14
|
+
const INK = "#1f2329";
|
|
15
|
+
const MUTED = "#5a6069";
|
|
16
|
+
const TITLE_SIZE = 16;
|
|
17
|
+
const LABEL_SIZE = 12;
|
|
18
|
+
const TICK_SIZE = 11;
|
|
19
|
+
function colorAt(spec, index) {
|
|
20
|
+
const custom = spec.colors?.[index % Math.max(1, spec.colors.length)];
|
|
21
|
+
return custom ?? paletteColor(index);
|
|
22
|
+
}
|
|
23
|
+
function requireFinite(values, what) {
|
|
24
|
+
for (const value of values) {
|
|
25
|
+
if (!Number.isFinite(value))
|
|
26
|
+
throw new MediaFormatError(`${what} contains ${value}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Legend rows, laid out in one horizontal strip under the plot. */
|
|
30
|
+
function legendStrip(names, spec, y, width) {
|
|
31
|
+
const out = [];
|
|
32
|
+
const swatch = 10;
|
|
33
|
+
const gap = 16;
|
|
34
|
+
const widths = names.map((name) => swatch + 5 + estimateTextWidth(name, LABEL_SIZE));
|
|
35
|
+
const total = widths.reduce((a, b) => a + b, 0) + gap * Math.max(0, names.length - 1);
|
|
36
|
+
let x = Math.max(8, (width - total) / 2);
|
|
37
|
+
for (let i = 0; i < names.length; i++) {
|
|
38
|
+
out.push(rect(x, y - swatch + 2, swatch, swatch, colorAt(spec, i), 'rx="2"'));
|
|
39
|
+
out.push(text(x + swatch + 5, y, names[i], LABEL_SIZE, { fill: MUTED }));
|
|
40
|
+
x += widths[i] + gap;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** Margins, derived from what the chart actually has to show. */
|
|
45
|
+
function plotArea(spec, yTickLabels, legendRows) {
|
|
46
|
+
const widest = yTickLabels.reduce((w, label) => Math.max(w, estimateTextWidth(label, TICK_SIZE)), 0);
|
|
47
|
+
const left = Math.ceil(12 + widest + 8 + (spec.yLabel === undefined ? 0 : LABEL_SIZE + 6));
|
|
48
|
+
const top = spec.title === undefined ? 16 : 16 + TITLE_SIZE + 10;
|
|
49
|
+
const bottom = 16 + TICK_SIZE + 8 + (spec.xLabel === undefined ? 0 : LABEL_SIZE + 6) + legendRows * 22;
|
|
50
|
+
return { left, top, right: spec.width - 16, bottom: spec.height - bottom };
|
|
51
|
+
}
|
|
52
|
+
function axisFrame(spec, plot, yTicks, yMin, yMax) {
|
|
53
|
+
const out = [];
|
|
54
|
+
const span = yMax - yMin;
|
|
55
|
+
for (const tick of yTicks) {
|
|
56
|
+
const y = plot.bottom - ((tick - yMin) / span) * (plot.bottom - plot.top);
|
|
57
|
+
out.push(line(plot.left, y, plot.right, y, GRID, 1));
|
|
58
|
+
out.push(text(plot.left - 6, y, formatValue(tick), TICK_SIZE, {
|
|
59
|
+
fill: MUTED,
|
|
60
|
+
anchor: "end",
|
|
61
|
+
baseline: "middle",
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
out.push(line(plot.left, plot.top, plot.left, plot.bottom, AXIS, 1));
|
|
65
|
+
out.push(line(plot.left, plot.bottom, plot.right, plot.bottom, AXIS, 1));
|
|
66
|
+
if (spec.yLabel !== undefined) {
|
|
67
|
+
const cx = 14;
|
|
68
|
+
const cy = (plot.top + plot.bottom) / 2;
|
|
69
|
+
out.push(`<g transform="rotate(-90 ${num(cx)} ${num(cy)})">${text(cx, cy, spec.yLabel, LABEL_SIZE, { fill: MUTED, anchor: "middle", baseline: "middle" })}</g>`);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function renderBar(spec) {
|
|
74
|
+
const categories = spec.categories ?? [];
|
|
75
|
+
const series = spec.series ?? [];
|
|
76
|
+
if (categories.length === 0)
|
|
77
|
+
throw new MediaFormatError("a bar chart needs `categories`");
|
|
78
|
+
if (series.length === 0)
|
|
79
|
+
throw new MediaFormatError("a bar chart needs at least one series");
|
|
80
|
+
for (const s of series) {
|
|
81
|
+
if (s.values === undefined) {
|
|
82
|
+
throw new MediaFormatError(`bar series "${s.name}" needs \`values\``);
|
|
83
|
+
}
|
|
84
|
+
if (s.values.length !== categories.length) {
|
|
85
|
+
throw new MediaFormatError(`bar series "${s.name}" has ${s.values.length} values but there are ${categories.length} categories`);
|
|
86
|
+
}
|
|
87
|
+
requireFinite(s.values, `bar series "${s.name}"`);
|
|
88
|
+
}
|
|
89
|
+
const stacked = spec.stacked === true && series.length > 1;
|
|
90
|
+
const columnTotals = categories.map((_, i) => series.reduce((sum, s) => sum + s.values[i], 0));
|
|
91
|
+
const flat = series.flatMap((s) => [...s.values]);
|
|
92
|
+
const dataMin = stacked ? Math.min(0, ...columnTotals) : Math.min(0, ...flat);
|
|
93
|
+
const dataMax = stacked ? Math.max(0, ...columnTotals) : Math.max(0, ...flat);
|
|
94
|
+
const scale = niceScale(dataMin, dataMax, 5);
|
|
95
|
+
const legendRows = (spec.legend ?? series.length > 1) ? 1 : 0;
|
|
96
|
+
const plot = plotArea(spec, scale.ticks.map(formatValue), legendRows);
|
|
97
|
+
const body = [];
|
|
98
|
+
if (spec.title !== undefined) {
|
|
99
|
+
body.push(text(spec.width / 2, 16 + TITLE_SIZE, spec.title, TITLE_SIZE, {
|
|
100
|
+
fill: INK,
|
|
101
|
+
anchor: "middle",
|
|
102
|
+
weight: "bold",
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
body.push(...axisFrame(spec, plot, scale.ticks, scale.min, scale.max));
|
|
106
|
+
const plotWidth = plot.right - plot.left;
|
|
107
|
+
const plotHeight = plot.bottom - plot.top;
|
|
108
|
+
const span = scale.max - scale.min;
|
|
109
|
+
const slot = plotWidth / categories.length;
|
|
110
|
+
const bandPadding = Math.min(12, slot * 0.2);
|
|
111
|
+
const band = slot - bandPadding;
|
|
112
|
+
const barWidth = stacked ? band : band / series.length;
|
|
113
|
+
const zeroY = plot.bottom - ((0 - scale.min) / span) * plotHeight;
|
|
114
|
+
for (let c = 0; c < categories.length; c++) {
|
|
115
|
+
const slotLeft = plot.left + c * slot + bandPadding / 2;
|
|
116
|
+
let stackTop = 0;
|
|
117
|
+
let stackBottom = 0;
|
|
118
|
+
for (let s = 0; s < series.length; s++) {
|
|
119
|
+
const value = (series[s]?.values)[c];
|
|
120
|
+
let y0;
|
|
121
|
+
let y1;
|
|
122
|
+
if (stacked) {
|
|
123
|
+
const base = value >= 0 ? stackTop : stackBottom;
|
|
124
|
+
const tip = base + value;
|
|
125
|
+
if (value >= 0)
|
|
126
|
+
stackTop = tip;
|
|
127
|
+
else
|
|
128
|
+
stackBottom = tip;
|
|
129
|
+
y0 = plot.bottom - ((Math.max(base, tip) - scale.min) / span) * plotHeight;
|
|
130
|
+
y1 = plot.bottom - ((Math.min(base, tip) - scale.min) / span) * plotHeight;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const valueY = plot.bottom - ((value - scale.min) / span) * plotHeight;
|
|
134
|
+
y0 = Math.min(valueY, zeroY);
|
|
135
|
+
y1 = Math.max(valueY, zeroY);
|
|
136
|
+
}
|
|
137
|
+
const x = stacked ? slotLeft : slotLeft + s * barWidth;
|
|
138
|
+
body.push(rect(x, y0, Math.max(1, barWidth - (stacked ? 0 : 1)), Math.max(0, y1 - y0), colorAt(spec, s)));
|
|
139
|
+
}
|
|
140
|
+
body.push(text(slotLeft + band / 2, plot.bottom + TICK_SIZE + 6, categories[c], TICK_SIZE, {
|
|
141
|
+
fill: MUTED,
|
|
142
|
+
anchor: "middle",
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
if (spec.xLabel !== undefined) {
|
|
146
|
+
body.push(text((plot.left + plot.right) / 2, plot.bottom + TICK_SIZE + 6 + LABEL_SIZE + 6, spec.xLabel, LABEL_SIZE, { fill: MUTED, anchor: "middle" }));
|
|
147
|
+
}
|
|
148
|
+
if (legendRows > 0) {
|
|
149
|
+
body.push(...legendStrip(series.map((s) => s.name), spec, spec.height - 12, spec.width));
|
|
150
|
+
}
|
|
151
|
+
return svgDocument(spec.width, spec.height, spec.background ?? "#ffffff", body, spec.title);
|
|
152
|
+
}
|
|
153
|
+
function renderXy(spec, mode) {
|
|
154
|
+
const series = spec.series ?? [];
|
|
155
|
+
if (series.length === 0)
|
|
156
|
+
throw new MediaFormatError(`a ${mode} chart needs at least one series`);
|
|
157
|
+
for (const s of series) {
|
|
158
|
+
if (s.points === undefined || s.points.length === 0) {
|
|
159
|
+
throw new MediaFormatError(`${mode} series "${s.name}" needs a non-empty \`points\` array`);
|
|
160
|
+
}
|
|
161
|
+
requireFinite(s.points.flatMap((p) => [p[0], p[1]]), `${mode} series "${s.name}"`);
|
|
162
|
+
}
|
|
163
|
+
const xs = series.flatMap((s) => s.points.map((p) => p[0]));
|
|
164
|
+
const ys = series.flatMap((s) => s.points.map((p) => p[1]));
|
|
165
|
+
const xScale = niceScale(Math.min(...xs), Math.max(...xs), 6);
|
|
166
|
+
const yScale = niceScale(Math.min(...ys), Math.max(...ys), 5);
|
|
167
|
+
const legendRows = (spec.legend ?? series.length > 1) ? 1 : 0;
|
|
168
|
+
const plot = plotArea(spec, yScale.ticks.map(formatValue), legendRows);
|
|
169
|
+
const body = [];
|
|
170
|
+
if (spec.title !== undefined) {
|
|
171
|
+
body.push(text(spec.width / 2, 16 + TITLE_SIZE, spec.title, TITLE_SIZE, {
|
|
172
|
+
fill: INK,
|
|
173
|
+
anchor: "middle",
|
|
174
|
+
weight: "bold",
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
body.push(...axisFrame(spec, plot, yScale.ticks, yScale.min, yScale.max));
|
|
178
|
+
const toX = (value) => plot.left + ((value - xScale.min) / (xScale.max - xScale.min)) * (plot.right - plot.left);
|
|
179
|
+
const toY = (value) => plot.bottom - ((value - yScale.min) / (yScale.max - yScale.min)) * (plot.bottom - plot.top);
|
|
180
|
+
for (const tick of xScale.ticks) {
|
|
181
|
+
body.push(text(toX(tick), plot.bottom + TICK_SIZE + 6, formatValue(tick), TICK_SIZE, {
|
|
182
|
+
fill: MUTED,
|
|
183
|
+
anchor: "middle",
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
for (let s = 0; s < series.length; s++) {
|
|
187
|
+
const points = series[s]?.points;
|
|
188
|
+
// Sorted by x so a line never doubles back on itself for the same data
|
|
189
|
+
// given in a different order — the same series always draws the same.
|
|
190
|
+
const ordered = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
191
|
+
const mapped = ordered.map((p) => [toX(p[0]), toY(p[1])]);
|
|
192
|
+
const color = colorAt(spec, s);
|
|
193
|
+
if (mode === "line") {
|
|
194
|
+
body.push(polyline(mapped, color, 2));
|
|
195
|
+
for (const [x, y] of mapped)
|
|
196
|
+
body.push(circle(x, y, 2.5, color));
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
for (const [x, y] of mapped)
|
|
200
|
+
body.push(circle(x, y, 3.5, color));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (spec.xLabel !== undefined) {
|
|
204
|
+
body.push(text((plot.left + plot.right) / 2, plot.bottom + TICK_SIZE + 6 + LABEL_SIZE + 6, spec.xLabel, LABEL_SIZE, { fill: MUTED, anchor: "middle" }));
|
|
205
|
+
}
|
|
206
|
+
if (legendRows > 0) {
|
|
207
|
+
body.push(...legendStrip(series.map((s) => s.name), spec, spec.height - 12, spec.width));
|
|
208
|
+
}
|
|
209
|
+
return svgDocument(spec.width, spec.height, spec.background ?? "#ffffff", body, spec.title);
|
|
210
|
+
}
|
|
211
|
+
function renderPie(spec) {
|
|
212
|
+
const slices = spec.slices ?? [];
|
|
213
|
+
if (slices.length === 0)
|
|
214
|
+
throw new MediaFormatError("a pie chart needs `slices`");
|
|
215
|
+
requireFinite(slices.map((s) => s.value), "pie slices");
|
|
216
|
+
for (const slice of slices) {
|
|
217
|
+
if (slice.value < 0) {
|
|
218
|
+
throw new MediaFormatError(`slice "${slice.label}" is ${slice.value}; a pie cannot show a negative share`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
|
222
|
+
if (total <= 0)
|
|
223
|
+
throw new MediaFormatError("every slice is zero, so there is no pie to draw");
|
|
224
|
+
const body = [];
|
|
225
|
+
const top = spec.title === undefined ? 16 : 16 + TITLE_SIZE + 10;
|
|
226
|
+
if (spec.title !== undefined) {
|
|
227
|
+
body.push(text(spec.width / 2, 16 + TITLE_SIZE, spec.title, TITLE_SIZE, {
|
|
228
|
+
fill: INK,
|
|
229
|
+
anchor: "middle",
|
|
230
|
+
weight: "bold",
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
const legendHeight = 22 * Math.ceil(slices.length / 3) + 8;
|
|
234
|
+
const available = spec.height - top - legendHeight - 12;
|
|
235
|
+
const radius = Math.max(10, Math.min(available, spec.width - 32) / 2);
|
|
236
|
+
const cx = spec.width / 2;
|
|
237
|
+
const cy = top + available / 2;
|
|
238
|
+
let angle = -Math.PI / 2; // start at twelve o'clock
|
|
239
|
+
for (let i = 0; i < slices.length; i++) {
|
|
240
|
+
const slice = slices[i];
|
|
241
|
+
const sweep = (slice.value / total) * Math.PI * 2;
|
|
242
|
+
const color = colorAt(spec, i);
|
|
243
|
+
if (sweep <= 0)
|
|
244
|
+
continue;
|
|
245
|
+
if (sweep >= Math.PI * 2 - 1e-9) {
|
|
246
|
+
body.push(circle(cx, cy, radius, color));
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const x0 = cx + radius * Math.cos(angle);
|
|
250
|
+
const y0 = cy + radius * Math.sin(angle);
|
|
251
|
+
const x1 = cx + radius * Math.cos(angle + sweep);
|
|
252
|
+
const y1 = cy + radius * Math.sin(angle + sweep);
|
|
253
|
+
const largeArc = sweep > Math.PI ? 1 : 0;
|
|
254
|
+
body.push(`<path d="M ${num(cx)} ${num(cy)} L ${num(x0)} ${num(y0)} A ${num(radius)} ${num(radius)} 0 ${largeArc} 1 ${num(x1)} ${num(y1)} Z" fill="${escapeXml(color)}"/>`);
|
|
255
|
+
}
|
|
256
|
+
angle += sweep;
|
|
257
|
+
}
|
|
258
|
+
// Legend: three columns, each row a swatch, a label and the share.
|
|
259
|
+
const columns = Math.min(3, slices.length);
|
|
260
|
+
const columnWidth = (spec.width - 32) / columns;
|
|
261
|
+
for (let i = 0; i < slices.length; i++) {
|
|
262
|
+
const slice = slices[i];
|
|
263
|
+
const column = i % columns;
|
|
264
|
+
const row = Math.floor(i / columns);
|
|
265
|
+
const x = 16 + column * columnWidth;
|
|
266
|
+
const y = spec.height - legendHeight + 12 + row * 22;
|
|
267
|
+
const percent = Math.round((slice.value / total) * 1000) / 10;
|
|
268
|
+
body.push(rect(x, y - 9, 10, 10, colorAt(spec, i), 'rx="2"'));
|
|
269
|
+
body.push(text(x + 15, y, `${slice.label} (${num(percent)}%)`, LABEL_SIZE, { fill: MUTED }));
|
|
270
|
+
}
|
|
271
|
+
return svgDocument(spec.width, spec.height, spec.background ?? "#ffffff", body, spec.title);
|
|
272
|
+
}
|
|
273
|
+
/** The font stack the renderers name, re-exported for the tests. */
|
|
274
|
+
export const CHART_FONT_STACK = FONT_STACK;
|
|
275
|
+
/** Render a chart spec to a complete SVG document. */
|
|
276
|
+
export function renderChart(spec) {
|
|
277
|
+
if (spec.width < 120 || spec.height < 80) {
|
|
278
|
+
throw new MediaFormatError(`${spec.width}x${spec.height} is too small to draw a readable chart`);
|
|
279
|
+
}
|
|
280
|
+
switch (spec.type) {
|
|
281
|
+
case "bar":
|
|
282
|
+
return renderBar(spec);
|
|
283
|
+
case "line":
|
|
284
|
+
return renderXy(spec, "line");
|
|
285
|
+
case "scatter":
|
|
286
|
+
return renderXy(spec, "scatter");
|
|
287
|
+
case "pie":
|
|
288
|
+
return renderPie(spec);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export type Rgb = {
|
|
2
|
+
readonly r: number;
|
|
3
|
+
readonly g: number;
|
|
4
|
+
readonly b: number;
|
|
5
|
+
readonly a: number;
|
|
6
|
+
};
|
|
7
|
+
/** Colour keywords this package accepts, sorted. */
|
|
8
|
+
export declare function colorKeywords(): ReadonlyArray<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`,
|
|
11
|
+
* `hsla()` or one of the basic colour keywords. Throws `MediaFormatError`
|
|
12
|
+
* with the offending text on anything else.
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseColor(input: string): Rgb;
|
|
15
|
+
/** HSL with hue in degrees and saturation/lightness in percent, to 8-bit RGB. */
|
|
16
|
+
export declare function hslToRgb(h: number, s: number, l: number): {
|
|
17
|
+
r: number;
|
|
18
|
+
g: number;
|
|
19
|
+
b: number;
|
|
20
|
+
};
|
|
21
|
+
/** 8-bit RGB to HSL, hue in degrees, saturation and lightness in percent. */
|
|
22
|
+
export declare function rgbToHsl(r: number, g: number, b: number): {
|
|
23
|
+
h: number;
|
|
24
|
+
s: number;
|
|
25
|
+
l: number;
|
|
26
|
+
};
|
|
27
|
+
/** `#rrggbb`, or `#rrggbbaa` when the colour is not fully opaque. */
|
|
28
|
+
export declare function toHexString(color: Rgb): string;
|
|
29
|
+
/** WCAG 2.x relative luminance of an opaque sRGB colour. */
|
|
30
|
+
export declare function relativeLuminance(r: number, g: number, b: number): number;
|
|
31
|
+
/**
|
|
32
|
+
* Composite a possibly-translucent colour over an opaque backdrop. WCAG's
|
|
33
|
+
* ratio is defined for opaque colours only, so a caller passing `#0008`
|
|
34
|
+
* gets a defensible answer rather than a wrong one.
|
|
35
|
+
*/
|
|
36
|
+
export declare function compositeOver(top: Rgb, backdrop: Rgb): Rgb;
|
|
37
|
+
export type ContrastReport = {
|
|
38
|
+
readonly ratio: number;
|
|
39
|
+
readonly foreground: string;
|
|
40
|
+
readonly background: string;
|
|
41
|
+
readonly normalTextAA: boolean;
|
|
42
|
+
readonly normalTextAAA: boolean;
|
|
43
|
+
readonly largeTextAA: boolean;
|
|
44
|
+
readonly largeTextAAA: boolean;
|
|
45
|
+
readonly uiComponentAA: boolean;
|
|
46
|
+
/** The highest level the pair clears for body text. */
|
|
47
|
+
readonly verdict: "AAA" | "AA" | "AA-large-only" | "fail";
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Contrast ratio and the levels it clears. "Large" text is WCAG's own
|
|
51
|
+
* definition: at least 18pt, or 14pt bold.
|
|
52
|
+
*/
|
|
53
|
+
export declare function contrastReport(foreground: Rgb, background: Rgb): ContrastReport;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour parsing, conversion and the WCAG contrast check.
|
|
3
|
+
*
|
|
4
|
+
* The contrast maths is WCAG 2.x (the version every audit tool and every
|
|
5
|
+
* procurement checklist still keys on): sRGB channels linearised with the
|
|
6
|
+
* 0.03928 piecewise curve, luminance weighted 0.2126/0.7152/0.0722, ratio
|
|
7
|
+
* `(lighter + 0.05) / (darker + 0.05)`.
|
|
8
|
+
*/
|
|
9
|
+
import { MediaFormatError } from "./bytes";
|
|
10
|
+
const HEX3 = /^#?([0-9a-f])([0-9a-f])([0-9a-f])$/i;
|
|
11
|
+
const HEX4 = /^#?([0-9a-f])([0-9a-f])([0-9a-f])([0-9a-f])$/i;
|
|
12
|
+
const HEX6 = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
|
|
13
|
+
const HEX8 = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i;
|
|
14
|
+
const RGB_FN = /^rgba?\(\s*([^)]+)\)$/i;
|
|
15
|
+
const HSL_FN = /^hsla?\(\s*([^)]+)\)$/i;
|
|
16
|
+
/**
|
|
17
|
+
* The 16 colour keywords every CSS level has had, lowercase. The full
|
|
18
|
+
* 148-name list is deliberately not carried here: a caller who means
|
|
19
|
+
* `rebeccapurple` can write `#663399`, and a half-remembered table is
|
|
20
|
+
* worse than none.
|
|
21
|
+
*/
|
|
22
|
+
const KEYWORDS = Object.freeze({
|
|
23
|
+
black: "#000000",
|
|
24
|
+
silver: "#c0c0c0",
|
|
25
|
+
gray: "#808080",
|
|
26
|
+
grey: "#808080",
|
|
27
|
+
white: "#ffffff",
|
|
28
|
+
maroon: "#800000",
|
|
29
|
+
red: "#ff0000",
|
|
30
|
+
purple: "#800080",
|
|
31
|
+
fuchsia: "#ff00ff",
|
|
32
|
+
green: "#008000",
|
|
33
|
+
lime: "#00ff00",
|
|
34
|
+
olive: "#808000",
|
|
35
|
+
yellow: "#ffff00",
|
|
36
|
+
navy: "#000080",
|
|
37
|
+
blue: "#0000ff",
|
|
38
|
+
teal: "#008080",
|
|
39
|
+
aqua: "#00ffff",
|
|
40
|
+
transparent: "#00000000",
|
|
41
|
+
});
|
|
42
|
+
/** Colour keywords this package accepts, sorted. */
|
|
43
|
+
export function colorKeywords() {
|
|
44
|
+
return Object.keys(KEYWORDS).sort();
|
|
45
|
+
}
|
|
46
|
+
function clamp(value, low, high) {
|
|
47
|
+
return value < low ? low : value > high ? high : value;
|
|
48
|
+
}
|
|
49
|
+
function channel(text) {
|
|
50
|
+
const trimmed = text.trim();
|
|
51
|
+
if (trimmed.endsWith("%")) {
|
|
52
|
+
const pct = Number.parseFloat(trimmed.slice(0, -1));
|
|
53
|
+
if (Number.isNaN(pct))
|
|
54
|
+
throw new MediaFormatError(`"${text}" is not a percentage`);
|
|
55
|
+
return clamp(Math.round((pct / 100) * 255), 0, 255);
|
|
56
|
+
}
|
|
57
|
+
const value = Number.parseFloat(trimmed);
|
|
58
|
+
if (Number.isNaN(value))
|
|
59
|
+
throw new MediaFormatError(`"${text}" is not a channel value`);
|
|
60
|
+
return clamp(Math.round(value), 0, 255);
|
|
61
|
+
}
|
|
62
|
+
function alphaValue(text) {
|
|
63
|
+
if (text === undefined)
|
|
64
|
+
return 255;
|
|
65
|
+
const trimmed = text.trim();
|
|
66
|
+
const raw = trimmed.endsWith("%")
|
|
67
|
+
? Number.parseFloat(trimmed.slice(0, -1)) / 100
|
|
68
|
+
: Number.parseFloat(trimmed);
|
|
69
|
+
if (Number.isNaN(raw))
|
|
70
|
+
throw new MediaFormatError(`"${text}" is not an alpha value`);
|
|
71
|
+
return clamp(Math.round(raw * 255), 0, 255);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Parse `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`,
|
|
75
|
+
* `hsla()` or one of the basic colour keywords. Throws `MediaFormatError`
|
|
76
|
+
* with the offending text on anything else.
|
|
77
|
+
*/
|
|
78
|
+
export function parseColor(input) {
|
|
79
|
+
const text = input.trim();
|
|
80
|
+
const keyword = KEYWORDS[text.toLowerCase()];
|
|
81
|
+
if (keyword !== undefined)
|
|
82
|
+
return parseColor(keyword);
|
|
83
|
+
const hex8 = HEX8.exec(text);
|
|
84
|
+
if (hex8) {
|
|
85
|
+
return {
|
|
86
|
+
r: Number.parseInt(hex8[1], 16),
|
|
87
|
+
g: Number.parseInt(hex8[2], 16),
|
|
88
|
+
b: Number.parseInt(hex8[3], 16),
|
|
89
|
+
a: Number.parseInt(hex8[4], 16),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const hex6 = HEX6.exec(text);
|
|
93
|
+
if (hex6) {
|
|
94
|
+
return {
|
|
95
|
+
r: Number.parseInt(hex6[1], 16),
|
|
96
|
+
g: Number.parseInt(hex6[2], 16),
|
|
97
|
+
b: Number.parseInt(hex6[3], 16),
|
|
98
|
+
a: 255,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const hex4 = HEX4.exec(text);
|
|
102
|
+
if (hex4) {
|
|
103
|
+
const double = (d) => Number.parseInt(d + d, 16);
|
|
104
|
+
return {
|
|
105
|
+
r: double(hex4[1]),
|
|
106
|
+
g: double(hex4[2]),
|
|
107
|
+
b: double(hex4[3]),
|
|
108
|
+
a: double(hex4[4]),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const hex3 = HEX3.exec(text);
|
|
112
|
+
if (hex3) {
|
|
113
|
+
const double = (d) => Number.parseInt(d + d, 16);
|
|
114
|
+
return {
|
|
115
|
+
r: double(hex3[1]),
|
|
116
|
+
g: double(hex3[2]),
|
|
117
|
+
b: double(hex3[3]),
|
|
118
|
+
a: 255,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const rgbFn = RGB_FN.exec(text);
|
|
122
|
+
if (rgbFn) {
|
|
123
|
+
const parts = rgbFn[1]
|
|
124
|
+
.split(/[,/]/)
|
|
125
|
+
.map((p) => p.trim())
|
|
126
|
+
.filter((p) => p !== "");
|
|
127
|
+
if (parts.length < 3)
|
|
128
|
+
throw new MediaFormatError(`"${input}" needs three channels`);
|
|
129
|
+
return {
|
|
130
|
+
r: channel(parts[0]),
|
|
131
|
+
g: channel(parts[1]),
|
|
132
|
+
b: channel(parts[2]),
|
|
133
|
+
a: alphaValue(parts[3]),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const hslFn = HSL_FN.exec(text);
|
|
137
|
+
if (hslFn) {
|
|
138
|
+
const parts = hslFn[1]
|
|
139
|
+
.split(/[,/]/)
|
|
140
|
+
.map((p) => p.trim())
|
|
141
|
+
.filter((p) => p !== "");
|
|
142
|
+
if (parts.length < 3)
|
|
143
|
+
throw new MediaFormatError(`"${input}" needs hue, saturation and lightness`);
|
|
144
|
+
const h = Number.parseFloat(parts[0].replace(/deg$/i, ""));
|
|
145
|
+
const s = Number.parseFloat(parts[1].replace(/%$/, ""));
|
|
146
|
+
const l = Number.parseFloat(parts[2].replace(/%$/, ""));
|
|
147
|
+
if (Number.isNaN(h) || Number.isNaN(s) || Number.isNaN(l)) {
|
|
148
|
+
throw new MediaFormatError(`"${input}" has a component that is not a number`);
|
|
149
|
+
}
|
|
150
|
+
return { ...hslToRgb(h, s, l), a: alphaValue(parts[3]) };
|
|
151
|
+
}
|
|
152
|
+
throw new MediaFormatError(`"${input}" is not a colour this package reads (hex, rgb(), hsl(), or a basic keyword)`);
|
|
153
|
+
}
|
|
154
|
+
/** HSL with hue in degrees and saturation/lightness in percent, to 8-bit RGB. */
|
|
155
|
+
export function hslToRgb(h, s, l) {
|
|
156
|
+
const hue = ((h % 360) + 360) % 360;
|
|
157
|
+
const sat = clamp(s, 0, 100) / 100;
|
|
158
|
+
const light = clamp(l, 0, 100) / 100;
|
|
159
|
+
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
|
160
|
+
const hp = hue / 60;
|
|
161
|
+
const x = c * (1 - Math.abs((hp % 2) - 1));
|
|
162
|
+
let r = 0;
|
|
163
|
+
let g = 0;
|
|
164
|
+
let b = 0;
|
|
165
|
+
if (hp < 1)
|
|
166
|
+
[r, g, b] = [c, x, 0];
|
|
167
|
+
else if (hp < 2)
|
|
168
|
+
[r, g, b] = [x, c, 0];
|
|
169
|
+
else if (hp < 3)
|
|
170
|
+
[r, g, b] = [0, c, x];
|
|
171
|
+
else if (hp < 4)
|
|
172
|
+
[r, g, b] = [0, x, c];
|
|
173
|
+
else if (hp < 5)
|
|
174
|
+
[r, g, b] = [x, 0, c];
|
|
175
|
+
else
|
|
176
|
+
[r, g, b] = [c, 0, x];
|
|
177
|
+
const m = light - c / 2;
|
|
178
|
+
return {
|
|
179
|
+
r: Math.round((r + m) * 255),
|
|
180
|
+
g: Math.round((g + m) * 255),
|
|
181
|
+
b: Math.round((b + m) * 255),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** 8-bit RGB to HSL, hue in degrees, saturation and lightness in percent. */
|
|
185
|
+
export function rgbToHsl(r, g, b) {
|
|
186
|
+
const rn = r / 255;
|
|
187
|
+
const gn = g / 255;
|
|
188
|
+
const bn = b / 255;
|
|
189
|
+
const max = Math.max(rn, gn, bn);
|
|
190
|
+
const min = Math.min(rn, gn, bn);
|
|
191
|
+
const l = (max + min) / 2;
|
|
192
|
+
const d = max - min;
|
|
193
|
+
let h = 0;
|
|
194
|
+
let s = 0;
|
|
195
|
+
if (d !== 0) {
|
|
196
|
+
s = d / (1 - Math.abs(2 * l - 1));
|
|
197
|
+
if (max === rn)
|
|
198
|
+
h = ((gn - bn) / d) % 6;
|
|
199
|
+
else if (max === gn)
|
|
200
|
+
h = (bn - rn) / d + 2;
|
|
201
|
+
else
|
|
202
|
+
h = (rn - gn) / d + 4;
|
|
203
|
+
h *= 60;
|
|
204
|
+
if (h < 0)
|
|
205
|
+
h += 360;
|
|
206
|
+
}
|
|
207
|
+
const round1 = (v) => Math.round(v * 10) / 10;
|
|
208
|
+
return { h: round1(h), s: round1(s * 100), l: round1(l * 100) };
|
|
209
|
+
}
|
|
210
|
+
/** `#rrggbb`, or `#rrggbbaa` when the colour is not fully opaque. */
|
|
211
|
+
export function toHexString(color) {
|
|
212
|
+
const pair = (v) => v.toString(16).padStart(2, "0");
|
|
213
|
+
const base = `#${pair(color.r)}${pair(color.g)}${pair(color.b)}`;
|
|
214
|
+
return color.a === 255 ? base : `${base}${pair(color.a)}`;
|
|
215
|
+
}
|
|
216
|
+
/** WCAG 2.x relative luminance of an opaque sRGB colour. */
|
|
217
|
+
export function relativeLuminance(r, g, b) {
|
|
218
|
+
const linear = (value) => {
|
|
219
|
+
const c = value / 255;
|
|
220
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
221
|
+
};
|
|
222
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Composite a possibly-translucent colour over an opaque backdrop. WCAG's
|
|
226
|
+
* ratio is defined for opaque colours only, so a caller passing `#0008`
|
|
227
|
+
* gets a defensible answer rather than a wrong one.
|
|
228
|
+
*/
|
|
229
|
+
export function compositeOver(top, backdrop) {
|
|
230
|
+
const alpha = top.a / 255;
|
|
231
|
+
const mix = (t, b) => Math.round(t * alpha + b * (1 - alpha));
|
|
232
|
+
return {
|
|
233
|
+
r: mix(top.r, backdrop.r),
|
|
234
|
+
g: mix(top.g, backdrop.g),
|
|
235
|
+
b: mix(top.b, backdrop.b),
|
|
236
|
+
a: 255,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Contrast ratio and the levels it clears. "Large" text is WCAG's own
|
|
241
|
+
* definition: at least 18pt, or 14pt bold.
|
|
242
|
+
*/
|
|
243
|
+
export function contrastReport(foreground, background) {
|
|
244
|
+
const bg = background.a === 255
|
|
245
|
+
? background
|
|
246
|
+
: compositeOver(background, { r: 255, g: 255, b: 255, a: 255 });
|
|
247
|
+
const fg = foreground.a === 255 ? foreground : compositeOver(foreground, bg);
|
|
248
|
+
const lf = relativeLuminance(fg.r, fg.g, fg.b);
|
|
249
|
+
const lb = relativeLuminance(bg.r, bg.g, bg.b);
|
|
250
|
+
const lighter = Math.max(lf, lb);
|
|
251
|
+
const darker = Math.min(lf, lb);
|
|
252
|
+
const raw = (lighter + 0.05) / (darker + 0.05);
|
|
253
|
+
// Two decimal places is what every accessibility report quotes, and it
|
|
254
|
+
// keeps the number stable against floating-point noise.
|
|
255
|
+
const ratio = Math.round(raw * 100) / 100;
|
|
256
|
+
return {
|
|
257
|
+
ratio,
|
|
258
|
+
foreground: toHexString(fg),
|
|
259
|
+
background: toHexString(bg),
|
|
260
|
+
normalTextAA: ratio >= 4.5,
|
|
261
|
+
normalTextAAA: ratio >= 7,
|
|
262
|
+
largeTextAA: ratio >= 3,
|
|
263
|
+
largeTextAAA: ratio >= 4.5,
|
|
264
|
+
uiComponentAA: ratio >= 3,
|
|
265
|
+
verdict: ratio >= 7 ? "AAA" : ratio >= 4.5 ? "AA" : ratio >= 3 ? "AA-large-only" : "fail",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type DiagramNode = {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly label?: string;
|
|
4
|
+
};
|
|
5
|
+
export type DiagramEdge = {
|
|
6
|
+
readonly from: string;
|
|
7
|
+
readonly to: string;
|
|
8
|
+
readonly label?: string;
|
|
9
|
+
};
|
|
10
|
+
export type DiagramSpec = {
|
|
11
|
+
readonly nodes: ReadonlyArray<DiagramNode>;
|
|
12
|
+
readonly edges: ReadonlyArray<DiagramEdge>;
|
|
13
|
+
/** `"TB"` stacks layers downward; `"LR"` runs them left to right. */
|
|
14
|
+
readonly direction?: "TB" | "LR";
|
|
15
|
+
readonly title?: string;
|
|
16
|
+
readonly background?: string;
|
|
17
|
+
readonly nodeFill?: string;
|
|
18
|
+
readonly nodeStroke?: string;
|
|
19
|
+
};
|
|
20
|
+
export type DiagramResult = {
|
|
21
|
+
readonly svg: string;
|
|
22
|
+
readonly width: number;
|
|
23
|
+
readonly height: number;
|
|
24
|
+
/** Layer index per node id, in declaration order. */
|
|
25
|
+
readonly layers: ReadonlyArray<{
|
|
26
|
+
id: string;
|
|
27
|
+
layer: number;
|
|
28
|
+
}>;
|
|
29
|
+
/** Edges that close a cycle: drawn dashed, and not used for layering. */
|
|
30
|
+
readonly backEdges: ReadonlyArray<{
|
|
31
|
+
from: string;
|
|
32
|
+
to: string;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Layer assignment. A depth-first pass in declaration order finds the edges
|
|
37
|
+
* that close a cycle; the rest form a DAG, and each node's layer is one
|
|
38
|
+
* past its deepest remaining predecessor.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assignLayers(nodes: ReadonlyArray<DiagramNode>, edges: ReadonlyArray<DiagramEdge>): {
|
|
41
|
+
layers: Map<string, number>;
|
|
42
|
+
backEdges: Array<{
|
|
43
|
+
from: string;
|
|
44
|
+
to: string;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
/** Render a node and edge list to SVG with a layered layout. */
|
|
48
|
+
export declare function renderDiagram(spec: DiagramSpec): DiagramResult;
|