@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/LICENSE +13 -0
  3. package/README.md +132 -0
  4. package/bin/afixt-screenshot +14 -0
  5. package/dist/index.js +2797 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/index.mjs +2795 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +164 -0
  10. package/src/adapters/cdp.js +116 -0
  11. package/src/adapters/index.js +17 -0
  12. package/src/adapters/playwright.js +150 -0
  13. package/src/adapters/puppeteer.js +128 -0
  14. package/src/capture/element.js +257 -0
  15. package/src/capture/index.js +17 -0
  16. package/src/capture/long-page.js +267 -0
  17. package/src/capture/page.js +207 -0
  18. package/src/capture/responsive.js +94 -0
  19. package/src/capture/settle.js +182 -0
  20. package/src/capture/viewport.js +21 -0
  21. package/src/cli/index.js +266 -0
  22. package/src/compose/annotate.js +283 -0
  23. package/src/compose/diff.js +173 -0
  24. package/src/compose/heatmap.js +159 -0
  25. package/src/compose/index.js +8 -0
  26. package/src/compose/theme.js +45 -0
  27. package/src/errors/adapter.js +65 -0
  28. package/src/errors/base.js +48 -0
  29. package/src/errors/capture.js +175 -0
  30. package/src/errors/compose.js +78 -0
  31. package/src/errors/index.js +75 -0
  32. package/src/errors/optional.js +37 -0
  33. package/src/errors/redaction.js +42 -0
  34. package/src/errors/tile.js +61 -0
  35. package/src/errors/transform.js +78 -0
  36. package/src/index.js +69 -0
  37. package/src/redact/index.js +6 -0
  38. package/src/redact/policy.js +67 -0
  39. package/src/redact/redact.js +268 -0
  40. package/src/tile/dzi.js +268 -0
  41. package/src/tile/index.js +5 -0
  42. package/src/transform/convert.js +54 -0
  43. package/src/transform/crop.js +251 -0
  44. package/src/transform/index.js +8 -0
  45. package/src/transform/normalize.js +83 -0
  46. package/src/transform/resize.js +99 -0
  47. package/src/types/index.d.ts +432 -0
@@ -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('"', '&quot;').replaceAll('<', '&lt;');
252
+ }
253
+
254
+ function escapeText(s) {
255
+ return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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 };