@afixt/screenshot-utils 0.9.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/CHANGELOG.md +39 -0
- package/LICENSE +13 -0
- package/README.md +132 -0
- package/bin/afixt-screenshot +14 -0
- package/dist/index.js +2797 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2795 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +164 -0
- package/src/adapters/cdp.js +116 -0
- package/src/adapters/index.js +17 -0
- package/src/adapters/playwright.js +150 -0
- package/src/adapters/puppeteer.js +128 -0
- package/src/capture/element.js +257 -0
- package/src/capture/index.js +17 -0
- package/src/capture/long-page.js +267 -0
- package/src/capture/page.js +207 -0
- package/src/capture/responsive.js +94 -0
- package/src/capture/settle.js +182 -0
- package/src/capture/viewport.js +21 -0
- package/src/cli/index.js +266 -0
- package/src/compose/annotate.js +283 -0
- package/src/compose/diff.js +173 -0
- package/src/compose/heatmap.js +159 -0
- package/src/compose/index.js +8 -0
- package/src/compose/theme.js +45 -0
- package/src/errors/adapter.js +65 -0
- package/src/errors/base.js +48 -0
- package/src/errors/capture.js +175 -0
- package/src/errors/compose.js +78 -0
- package/src/errors/index.js +75 -0
- package/src/errors/optional.js +37 -0
- package/src/errors/redaction.js +42 -0
- package/src/errors/tile.js +61 -0
- package/src/errors/transform.js +78 -0
- package/src/index.js +69 -0
- package/src/redact/index.js +6 -0
- package/src/redact/policy.js +67 -0
- package/src/redact/redact.js +268 -0
- package/src/tile/dzi.js +268 -0
- package/src/tile/index.js +5 -0
- package/src/transform/convert.js +54 -0
- package/src/transform/crop.js +251 -0
- package/src/transform/index.js +8 -0
- package/src/transform/normalize.js +83 -0
- package/src/transform/resize.js +99 -0
- package/src/types/index.d.ts +432 -0
package/src/cli/index.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseArgs } = require('node:util');
|
|
4
|
+
|
|
5
|
+
const { crop } = require('../transform/crop.js');
|
|
6
|
+
const { convert } = require('../transform/convert.js');
|
|
7
|
+
const { tileDzi } = require('../tile/dzi.js');
|
|
8
|
+
const { diff } = require('../compose/diff.js');
|
|
9
|
+
|
|
10
|
+
const COMMANDS = {
|
|
11
|
+
crop: cropCommand,
|
|
12
|
+
convert: convertCommand,
|
|
13
|
+
tile: tileCommand,
|
|
14
|
+
diff: diffCommand,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const HELP = `Usage: afixt-screenshot <command> [...args]
|
|
18
|
+
|
|
19
|
+
Commands:
|
|
20
|
+
crop <input> Extract a rectangle from an image.
|
|
21
|
+
convert <input> Re-encode an image to png/jpeg/webp.
|
|
22
|
+
tile <input> Generate a DZI tile pyramid from an image.
|
|
23
|
+
diff <a> <b> Visual diff between two images (requires pixelmatch).
|
|
24
|
+
|
|
25
|
+
Run "afixt-screenshot <command> --help" for command-specific options.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* CLI entry. Returns the process exit code (0 = success).
|
|
30
|
+
*
|
|
31
|
+
* @public
|
|
32
|
+
* @param {string[]} argv The argv array minus the node binary + script path
|
|
33
|
+
* (i.e. process.argv.slice(2)).
|
|
34
|
+
* @returns {Promise<number>}
|
|
35
|
+
*/
|
|
36
|
+
async function main(argv) {
|
|
37
|
+
const [command, ...rest] = argv;
|
|
38
|
+
if (!command || command === '--help' || command === '-h') {
|
|
39
|
+
process.stdout.write(HELP);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (!Object.hasOwn(COMMANDS, command)) {
|
|
43
|
+
process.stderr.write(`Unknown command: ${command}\n${HELP}`);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
await COMMANDS[command](rest);
|
|
48
|
+
return 0;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
const code = error?.code ? ` [${error.code}]` : '';
|
|
51
|
+
process.stderr.write(`Error${code}: ${error?.message ?? String(error)}\n`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function cropCommand(args) {
|
|
57
|
+
const { values, positionals } = parseArgs({
|
|
58
|
+
args,
|
|
59
|
+
options: {
|
|
60
|
+
rect: { type: 'string' },
|
|
61
|
+
padding: { type: 'string' },
|
|
62
|
+
format: { type: 'string' },
|
|
63
|
+
quality: { type: 'string' },
|
|
64
|
+
out: { type: 'string', short: 'o' },
|
|
65
|
+
help: { type: 'boolean', short: 'h' },
|
|
66
|
+
},
|
|
67
|
+
allowPositionals: true,
|
|
68
|
+
});
|
|
69
|
+
if (values.help) {
|
|
70
|
+
process.stdout.write(
|
|
71
|
+
'Usage: afixt-screenshot crop <input> --rect x,y,w,h [--padding N] [--format png|jpeg|webp] [--quality 1-100] [--out file]\n'
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const [input] = positionals;
|
|
76
|
+
if (!input) {
|
|
77
|
+
throw new Error('crop requires an input file path');
|
|
78
|
+
}
|
|
79
|
+
if (!values.rect) {
|
|
80
|
+
throw new Error('crop requires --rect x,y,width,height');
|
|
81
|
+
}
|
|
82
|
+
const rect = parseRect(values.rect);
|
|
83
|
+
const opts = {};
|
|
84
|
+
if (values.padding !== undefined) {
|
|
85
|
+
opts.padding = Number(values.padding);
|
|
86
|
+
}
|
|
87
|
+
if (values.format) {
|
|
88
|
+
opts.format = values.format;
|
|
89
|
+
}
|
|
90
|
+
if (values.quality !== undefined) {
|
|
91
|
+
opts.quality = Number(values.quality);
|
|
92
|
+
}
|
|
93
|
+
const buffer = await readBuffer(input);
|
|
94
|
+
const result = await crop(buffer, rect, opts);
|
|
95
|
+
await writeOutput(result, values.out);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function convertCommand(args) {
|
|
99
|
+
const { values, positionals } = parseArgs({
|
|
100
|
+
args,
|
|
101
|
+
options: {
|
|
102
|
+
format: { type: 'string' },
|
|
103
|
+
quality: { type: 'string' },
|
|
104
|
+
lossless: { type: 'boolean' },
|
|
105
|
+
out: { type: 'string', short: 'o' },
|
|
106
|
+
help: { type: 'boolean', short: 'h' },
|
|
107
|
+
},
|
|
108
|
+
allowPositionals: true,
|
|
109
|
+
});
|
|
110
|
+
if (values.help) {
|
|
111
|
+
process.stdout.write(
|
|
112
|
+
'Usage: afixt-screenshot convert <input> --format png|jpeg|webp [--quality 1-100] [--lossless] [--out file]\n'
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const [input] = positionals;
|
|
117
|
+
if (!input) {
|
|
118
|
+
throw new Error('convert requires an input file path');
|
|
119
|
+
}
|
|
120
|
+
if (!values.format) {
|
|
121
|
+
throw new Error('convert requires --format png|jpeg|webp');
|
|
122
|
+
}
|
|
123
|
+
const opts = { format: values.format };
|
|
124
|
+
if (values.quality !== undefined) {
|
|
125
|
+
opts.quality = Number(values.quality);
|
|
126
|
+
}
|
|
127
|
+
if (values.lossless) {
|
|
128
|
+
opts.lossless = true;
|
|
129
|
+
}
|
|
130
|
+
const buffer = await readBuffer(input);
|
|
131
|
+
const result = await convert(buffer, opts);
|
|
132
|
+
await writeOutput(result, values.out);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function tileCommand(args) {
|
|
136
|
+
const { values, positionals } = parseArgs({
|
|
137
|
+
args,
|
|
138
|
+
options: {
|
|
139
|
+
'tile-size': { type: 'string' },
|
|
140
|
+
overlap: { type: 'string' },
|
|
141
|
+
format: { type: 'string' },
|
|
142
|
+
quality: { type: 'string' },
|
|
143
|
+
out: { type: 'string', short: 'o' },
|
|
144
|
+
help: { type: 'boolean', short: 'h' },
|
|
145
|
+
},
|
|
146
|
+
allowPositionals: true,
|
|
147
|
+
});
|
|
148
|
+
if (values.help) {
|
|
149
|
+
process.stdout.write(
|
|
150
|
+
'Usage: afixt-screenshot tile <input> --out <directory> [--tile-size 256|512] [--overlap N] [--format png|jpeg|webp] [--quality 1-100]\n'
|
|
151
|
+
);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const [input] = positionals;
|
|
155
|
+
if (!input) {
|
|
156
|
+
throw new Error('tile requires an input file path');
|
|
157
|
+
}
|
|
158
|
+
const opts = { output: 'directory' };
|
|
159
|
+
if (values['tile-size']) {
|
|
160
|
+
opts.tileSize = Number(values['tile-size']);
|
|
161
|
+
}
|
|
162
|
+
if (values.overlap !== undefined) {
|
|
163
|
+
opts.overlap = Number(values.overlap);
|
|
164
|
+
}
|
|
165
|
+
if (values.format) {
|
|
166
|
+
opts.tileFormat = values.format;
|
|
167
|
+
}
|
|
168
|
+
if (values.quality !== undefined) {
|
|
169
|
+
opts.quality = Number(values.quality);
|
|
170
|
+
}
|
|
171
|
+
const buffer = await readBuffer(input);
|
|
172
|
+
const result = await tileDzi(buffer, opts);
|
|
173
|
+
if (values.out) {
|
|
174
|
+
// Move the temp dir to the requested location.
|
|
175
|
+
const fs = require('node:fs/promises');
|
|
176
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
177
|
+
await fs.rename(result.outputDir, values.out);
|
|
178
|
+
process.stdout.write(`Wrote DZI tree to ${values.out} (${result.totalTiles} tiles)\n`);
|
|
179
|
+
} else {
|
|
180
|
+
process.stdout.write(
|
|
181
|
+
`Wrote DZI tree to ${result.outputDir} (${result.totalTiles} tiles)\n`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function diffCommand(args) {
|
|
187
|
+
const { values, positionals } = parseArgs({
|
|
188
|
+
args,
|
|
189
|
+
options: {
|
|
190
|
+
threshold: { type: 'string' },
|
|
191
|
+
out: { type: 'string', short: 'o' },
|
|
192
|
+
output: { type: 'string' },
|
|
193
|
+
help: { type: 'boolean', short: 'h' },
|
|
194
|
+
},
|
|
195
|
+
allowPositionals: true,
|
|
196
|
+
});
|
|
197
|
+
if (values.help) {
|
|
198
|
+
process.stdout.write(
|
|
199
|
+
'Usage: afixt-screenshot diff <a> <b> [--threshold N] [--output overlay|side-by-side|mask-only] [--out file]\n'
|
|
200
|
+
);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const [a, b] = positionals;
|
|
204
|
+
if (!a || !b) {
|
|
205
|
+
throw new Error('diff requires two input file paths');
|
|
206
|
+
}
|
|
207
|
+
const opts = {};
|
|
208
|
+
if (values.threshold !== undefined) {
|
|
209
|
+
opts.threshold = Number(values.threshold);
|
|
210
|
+
}
|
|
211
|
+
if (values.output) {
|
|
212
|
+
opts.output = values.output;
|
|
213
|
+
}
|
|
214
|
+
const [bufferA, bufferB] = await Promise.all([readBuffer(a), readBuffer(b)]);
|
|
215
|
+
const result = await diff(bufferA, bufferB, opts);
|
|
216
|
+
process.stdout.write(
|
|
217
|
+
`${result.changedPixels} of ${result.totalPixels} pixels changed (${result.percentChanged.toFixed(2)}%)\n`
|
|
218
|
+
);
|
|
219
|
+
await writeOutput(result.diffBuffer, values.out);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read a file's contents as a Buffer.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} input
|
|
226
|
+
* @returns {Promise<Buffer>}
|
|
227
|
+
*/
|
|
228
|
+
async function readBuffer(input) {
|
|
229
|
+
const fs = require('node:fs/promises');
|
|
230
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
231
|
+
return fs.readFile(input);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Write the buffer to `out` if supplied; otherwise to stdout.
|
|
236
|
+
*
|
|
237
|
+
* @param {Buffer} buffer
|
|
238
|
+
* @param {string | undefined} out
|
|
239
|
+
*/
|
|
240
|
+
async function writeOutput(buffer, out) {
|
|
241
|
+
if (out) {
|
|
242
|
+
const fs = require('node:fs/promises');
|
|
243
|
+
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
|
244
|
+
await fs.writeFile(out, buffer);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
process.stdout.write(buffer);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Parse a comma-separated rect string into a Rect object.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} s
|
|
254
|
+
* @returns {{x: number, y: number, width: number, height: number}}
|
|
255
|
+
*/
|
|
256
|
+
function parseRect(s) {
|
|
257
|
+
const parts = String(s).split(',').map(Number);
|
|
258
|
+
if (parts.length !== 4 || parts.some(n => !Number.isFinite(n))) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`--rect must be four comma-separated numbers; received ${JSON.stringify(s)}`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { main };
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
BufferCorruptError,
|
|
5
|
+
ThemeError,
|
|
6
|
+
HeatmapPointError,
|
|
7
|
+
ComposeError,
|
|
8
|
+
} = require('../errors/index.js');
|
|
9
|
+
const { defaultTheme } = require('./theme.js');
|
|
10
|
+
|
|
11
|
+
const MAX_ANNOTATIONS = 5000;
|
|
12
|
+
const VALID_LEGEND_POSITIONS = new Set([
|
|
13
|
+
'none',
|
|
14
|
+
'top-left',
|
|
15
|
+
'top-right',
|
|
16
|
+
'bottom-left',
|
|
17
|
+
'bottom-right',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Render annotation overlays onto a screenshot buffer.
|
|
22
|
+
*
|
|
23
|
+
* Implements `spec.md` § 8.3.1 with pure-sharp rendering — annotations are
|
|
24
|
+
* serialized to an SVG layer and composited onto the source image. No
|
|
25
|
+
* `@napi-rs/canvas` dep in v1.0 (deferred to v1.1 per spec § 22).
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
* @param {Buffer} input
|
|
29
|
+
* @param {Array<import('../types/index.d.ts').Annotation>} annotations
|
|
30
|
+
* @param {import('../types/index.d.ts').AnnotateOptions} [options]
|
|
31
|
+
* @returns {Promise<Buffer>}
|
|
32
|
+
*/
|
|
33
|
+
async function annotate(input, annotations, options = {}) {
|
|
34
|
+
assertBuffer(input);
|
|
35
|
+
if (!Array.isArray(annotations)) {
|
|
36
|
+
throw new HeatmapPointError('annotations must be an array', {
|
|
37
|
+
context: { received: typeof annotations },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (annotations.length > MAX_ANNOTATIONS) {
|
|
41
|
+
throw new ComposeError(
|
|
42
|
+
`Cannot annotate more than ${MAX_ANNOTATIONS} regions in a single call`,
|
|
43
|
+
{ context: { received: annotations.length, max: MAX_ANNOTATIONS } }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const { theme: callerTheme, output = 'png', legend = 'none' } = options;
|
|
47
|
+
if (!VALID_LEGEND_POSITIONS.has(legend)) {
|
|
48
|
+
throw new ThemeError(
|
|
49
|
+
`legend must be one of ${[...VALID_LEGEND_POSITIONS].join(', ')}; received ${JSON.stringify(legend)}`,
|
|
50
|
+
{ context: { received: legend } }
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (output !== 'png' && output !== 'svg') {
|
|
54
|
+
throw new ComposeError(
|
|
55
|
+
`annotate output must be 'png' or 'svg'; received ${JSON.stringify(output)}`,
|
|
56
|
+
{ context: { received: output } }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const theme = mergeTheme(defaultTheme, callerTheme);
|
|
60
|
+
|
|
61
|
+
// Validate every annotation up-front so we fail fast.
|
|
62
|
+
for (const [i, ann] of annotations.entries()) {
|
|
63
|
+
assertAnnotation(ann, i, theme);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sharp = require('sharp');
|
|
67
|
+
const meta = await readMeta(sharp, input);
|
|
68
|
+
|
|
69
|
+
const svg = buildSvg(meta, annotations, theme, legend);
|
|
70
|
+
|
|
71
|
+
if (output === 'svg') {
|
|
72
|
+
return Buffer.from(svg, 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return sharp(input)
|
|
76
|
+
.composite([{ input: Buffer.from(svg, 'utf8'), top: 0, left: 0 }])
|
|
77
|
+
.toBuffer();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Shallow-merge a caller theme over the default. Missing keys fall back to
|
|
82
|
+
* the default; missing properties on a present key throw ThemeError so
|
|
83
|
+
* consumers can't silently render an invisible annotation.
|
|
84
|
+
*
|
|
85
|
+
* @param {import('../types/index.d.ts').AnnotationTheme} base
|
|
86
|
+
* @param {import('../types/index.d.ts').AnnotationTheme | undefined} extra
|
|
87
|
+
* @returns {import('../types/index.d.ts').AnnotationTheme}
|
|
88
|
+
*/
|
|
89
|
+
function mergeTheme(base, extra) {
|
|
90
|
+
if (!extra) {
|
|
91
|
+
return base;
|
|
92
|
+
}
|
|
93
|
+
const merged = { ...base };
|
|
94
|
+
for (const [key, style] of Object.entries(extra)) {
|
|
95
|
+
if (!style || typeof style !== 'object') {
|
|
96
|
+
throw new ThemeError(`Theme key '${key}' must be an object`, {
|
|
97
|
+
context: { key, received: typeof style },
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (typeof style.stroke !== 'string') {
|
|
101
|
+
throw new ThemeError(`Theme key '${key}' must have a 'stroke' color string`, {
|
|
102
|
+
context: { key },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
merged[key] = { ...base[key], ...style };
|
|
106
|
+
}
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Validate one entry from the annotations array. Throws HeatmapPointError
|
|
112
|
+
* for shape problems and ThemeError when the style key is unknown.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} ann
|
|
115
|
+
* @param {number} index
|
|
116
|
+
* @param {import('../types/index.d.ts').AnnotationTheme} theme
|
|
117
|
+
*/
|
|
118
|
+
function assertAnnotation(ann, index, theme) {
|
|
119
|
+
if (!ann || typeof ann !== 'object') {
|
|
120
|
+
throw new HeatmapPointError(`annotations[${index}] must be an object`, {
|
|
121
|
+
context: { index, received: typeof ann },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
const a = /** @type {Record<string, unknown>} */ (ann);
|
|
125
|
+
const rect = /** @type {Record<string, unknown> | undefined} */ (a.rect);
|
|
126
|
+
if (
|
|
127
|
+
!rect ||
|
|
128
|
+
!Number.isFinite(rect.x) ||
|
|
129
|
+
!Number.isFinite(rect.y) ||
|
|
130
|
+
!Number.isFinite(rect.width) ||
|
|
131
|
+
!Number.isFinite(rect.height)
|
|
132
|
+
) {
|
|
133
|
+
throw new HeatmapPointError(`annotations[${index}].rect must be a finite Rect`, {
|
|
134
|
+
context: { index, rect },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (typeof a.style !== 'string') {
|
|
138
|
+
throw new HeatmapPointError(`annotations[${index}].style must be a theme key string`, {
|
|
139
|
+
context: { index, style: a.style },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (!theme[a.style]) {
|
|
143
|
+
throw new ThemeError(
|
|
144
|
+
`annotations[${index}].style '${a.style}' has no matching theme entry`,
|
|
145
|
+
{ context: { index, style: a.style, available: Object.keys(theme) } }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the SVG overlay covering the full image. The SVG is rendered into
|
|
152
|
+
* the same pixel space as the source so coordinates need no scaling.
|
|
153
|
+
*
|
|
154
|
+
* @param {{width: number, height: number}} meta
|
|
155
|
+
* @param {Array<import('../types/index.d.ts').Annotation>} annotations
|
|
156
|
+
* @param {import('../types/index.d.ts').AnnotationTheme} theme
|
|
157
|
+
* @param {string} legend
|
|
158
|
+
* @returns {string}
|
|
159
|
+
*/
|
|
160
|
+
function buildSvg(meta, annotations, theme, legend) {
|
|
161
|
+
const layers = annotations.map(ann => svgLayerForAnnotation(ann, theme[ann.style])).join('\n');
|
|
162
|
+
const legendSvg = legend === 'none' ? '' : svgLegend(meta, annotations, theme, legend);
|
|
163
|
+
return [
|
|
164
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${meta.width}" height="${meta.height}" viewBox="0 0 ${meta.width} ${meta.height}">`,
|
|
165
|
+
'<g font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="14" font-weight="bold">',
|
|
166
|
+
layers,
|
|
167
|
+
legendSvg,
|
|
168
|
+
'</g>',
|
|
169
|
+
'</svg>',
|
|
170
|
+
].join('\n');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function svgLayerForAnnotation(ann, style) {
|
|
174
|
+
const { x, y, width, height } = ann.rect;
|
|
175
|
+
const parts = [
|
|
176
|
+
`<rect x="${x}" y="${y}" width="${width}" height="${height}" stroke="${escapeAttr(style.stroke)}" stroke-width="${style.strokeWidth}" fill="${escapeAttr(style.fill ?? 'none')}"/>`,
|
|
177
|
+
];
|
|
178
|
+
if (ann.label && style.badge) {
|
|
179
|
+
const badgeSize = 20;
|
|
180
|
+
const badgeX = x - badgeSize / 2;
|
|
181
|
+
const badgeY = y - badgeSize / 2;
|
|
182
|
+
parts.push(
|
|
183
|
+
`<rect x="${badgeX}" y="${badgeY}" width="${badgeSize}" height="${badgeSize}" rx="${badgeSize / 2}" fill="${escapeAttr(style.badge.bg)}"/>`,
|
|
184
|
+
`<text x="${badgeX + badgeSize / 2}" y="${badgeY + badgeSize / 2 + 4}" text-anchor="middle" fill="${escapeAttr(style.badge.fg)}">${escapeText(String(ann.label))}</text>`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return parts.join('');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a legend block listing each theme key that appears in the
|
|
192
|
+
* annotations.
|
|
193
|
+
*
|
|
194
|
+
* @param {{width: number, height: number}} meta
|
|
195
|
+
* @param {Array<import('../types/index.d.ts').Annotation>} annotations
|
|
196
|
+
* @param {import('../types/index.d.ts').AnnotationTheme} theme
|
|
197
|
+
* @param {string} position
|
|
198
|
+
* @returns {string}
|
|
199
|
+
*/
|
|
200
|
+
function svgLegend(meta, annotations, theme, position) {
|
|
201
|
+
const usedKeys = [...new Set(annotations.map(a => a.style))];
|
|
202
|
+
if (usedKeys.length === 0) {
|
|
203
|
+
return '';
|
|
204
|
+
}
|
|
205
|
+
const rowHeight = 22;
|
|
206
|
+
const padding = 10;
|
|
207
|
+
const swatchSize = 14;
|
|
208
|
+
const charWidth = 8;
|
|
209
|
+
const labelWidths = usedKeys.map(k => k.length * charWidth);
|
|
210
|
+
const width = padding * 2 + swatchSize + 6 + Math.max(...labelWidths);
|
|
211
|
+
const height = padding * 2 + usedKeys.length * rowHeight;
|
|
212
|
+
let x;
|
|
213
|
+
let y;
|
|
214
|
+
switch (position) {
|
|
215
|
+
case 'top-left':
|
|
216
|
+
x = padding;
|
|
217
|
+
y = padding;
|
|
218
|
+
break;
|
|
219
|
+
case 'top-right':
|
|
220
|
+
x = meta.width - width - padding;
|
|
221
|
+
y = padding;
|
|
222
|
+
break;
|
|
223
|
+
case 'bottom-left':
|
|
224
|
+
x = padding;
|
|
225
|
+
y = meta.height - height - padding;
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
// 'bottom-right' or anything unknown reaching this point.
|
|
229
|
+
x = meta.width - width - padding;
|
|
230
|
+
y = meta.height - height - padding;
|
|
231
|
+
}
|
|
232
|
+
const rows = usedKeys
|
|
233
|
+
.map((key, i) => {
|
|
234
|
+
const style = theme[key];
|
|
235
|
+
const rowY = y + padding + i * rowHeight;
|
|
236
|
+
return (
|
|
237
|
+
`<rect x="${x + padding}" y="${rowY}" width="${swatchSize}" height="${swatchSize}" ` +
|
|
238
|
+
`fill="${escapeAttr(style.fill ?? 'none')}" stroke="${escapeAttr(style.stroke)}" stroke-width="2"/>` +
|
|
239
|
+
`<text x="${x + padding + swatchSize + 6}" y="${rowY + swatchSize - 2}" fill="#222">${escapeText(key)}</text>`
|
|
240
|
+
);
|
|
241
|
+
})
|
|
242
|
+
.join('');
|
|
243
|
+
return (
|
|
244
|
+
`<g><rect x="${x}" y="${y}" width="${width}" height="${height}" fill="rgba(255,255,255,0.95)" stroke="#222" stroke-width="1"/>` +
|
|
245
|
+
rows +
|
|
246
|
+
'</g>'
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function escapeAttr(s) {
|
|
251
|
+
return String(s).replaceAll('"', '"').replaceAll('<', '<');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function escapeText(s) {
|
|
255
|
+
return String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function assertBuffer(input) {
|
|
259
|
+
if (!Buffer.isBuffer(input)) {
|
|
260
|
+
throw new BufferCorruptError('input must be a Buffer', {
|
|
261
|
+
context: { received: typeof input },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function readMeta(sharp, input) {
|
|
267
|
+
try {
|
|
268
|
+
const meta = await sharp(input).metadata();
|
|
269
|
+
if (!meta.width || !meta.height) {
|
|
270
|
+
throw new BufferCorruptError('Image has no detectable dimensions');
|
|
271
|
+
}
|
|
272
|
+
return { width: meta.width, height: meta.height };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (error instanceof BufferCorruptError) {
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
throw new BufferCorruptError('sharp could not read annotate input buffer', {
|
|
278
|
+
cause: error,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = { annotate, MAX_ANNOTATIONS };
|