@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,251 @@
1
+ 'use strict';
2
+
3
+ const { InvalidRectError, BufferCorruptError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Extract a single rectangle from a source image buffer.
7
+ *
8
+ * Implements `spec.md` § 8.2.1. All coordinates are CSS pixels. Padding
9
+ * expands the rect in all four directions and is then clamped to the
10
+ * image bounds (we never error on padding causing under/overflow — the
11
+ * caller asked for slack, not arithmetic).
12
+ *
13
+ * @public
14
+ * @param {Buffer} input
15
+ * @param {import('../types/index.d.ts').Rect} rect
16
+ * @param {import('../types/index.d.ts').CropOptions} [options]
17
+ * @returns {Promise<Buffer>}
18
+ */
19
+ async function crop(input, rect, options = {}) {
20
+ assertBuffer(input);
21
+ assertRect(rect);
22
+ const { padding = 0, format, quality = 90 } = options;
23
+ const sharp = require('sharp');
24
+
25
+ const meta = await readMeta(sharp, input);
26
+ const extract = computeExtract(rect, padding, meta);
27
+ if (extract.width <= 0 || extract.height <= 0) {
28
+ throw new InvalidRectError(
29
+ `Rect resolves to 0×0 after clamping to image bounds (${meta.width}×${meta.height})`,
30
+ { context: { rect, padding, imageWidth: meta.width, imageHeight: meta.height } }
31
+ );
32
+ }
33
+
34
+ let pipeline = sharp(input).extract(extract);
35
+ pipeline = applyFormat(pipeline, format, quality);
36
+ return pipeline.toBuffer();
37
+ }
38
+
39
+ /**
40
+ * Extract many rectangles in parallel from one source. Used by the engine's
41
+ * `_captureIssueScreenshots` migration path (one full-page capture + many
42
+ * crops beats N per-element captures by ~20×).
43
+ *
44
+ * Implements `spec.md` § 8.2.2.
45
+ *
46
+ * @public
47
+ * @param {Buffer} input
48
+ * @param {Array<{ id: string, rect: import('../types/index.d.ts').Rect }>} entries
49
+ * @param {import('../types/index.d.ts').CropManyOptions} [options]
50
+ * @returns {Promise<Map<string, Buffer>>}
51
+ */
52
+ async function cropMany(input, entries, options = {}) {
53
+ assertBuffer(input);
54
+ if (!Array.isArray(entries)) {
55
+ throw new InvalidRectError('cropMany entries must be an array', {
56
+ context: { received: typeof entries },
57
+ });
58
+ }
59
+ const { padding = 0, format, quality = 90, parallelism = 4 } = options;
60
+ const sharp = require('sharp');
61
+ const meta = await readMeta(sharp, input);
62
+
63
+ // Validate all rects up front so we fail fast (and uniformly) before
64
+ // doing any work, rather than partially producing crops then erroring.
65
+ const planned = entries.map(entry => {
66
+ if (!entry || typeof entry.id !== 'string') {
67
+ throw new InvalidRectError('Each cropMany entry must have a string id and a rect', {
68
+ context: { entry },
69
+ });
70
+ }
71
+ assertRect(entry.rect);
72
+ const extract = computeExtract(entry.rect, padding, meta);
73
+ if (extract.width <= 0 || extract.height <= 0) {
74
+ throw new InvalidRectError(
75
+ `Rect for id ${JSON.stringify(entry.id)} resolves to 0×0 after clamping`,
76
+ {
77
+ context: {
78
+ id: entry.id,
79
+ rect: entry.rect,
80
+ padding,
81
+ imageWidth: meta.width,
82
+ imageHeight: meta.height,
83
+ },
84
+ }
85
+ );
86
+ }
87
+ return { id: entry.id, extract };
88
+ });
89
+
90
+ const results = new Map();
91
+ await parallelEach(
92
+ planned,
93
+ async ({ id, extract }) => {
94
+ let pipeline = sharp(input).extract(extract);
95
+ pipeline = applyFormat(pipeline, format, quality);
96
+ const buffer = await pipeline.toBuffer();
97
+ results.set(id, buffer);
98
+ },
99
+ Math.max(1, parallelism)
100
+ );
101
+ return results;
102
+ }
103
+
104
+ /**
105
+ * Run `fn(item)` over every item with at most `limit` in flight at once.
106
+ * Order of completion is irrelevant; the caller stores results by id.
107
+ *
108
+ * @template T
109
+ * @param {readonly T[]} items
110
+ * @param {(item: T, index: number) => Promise<void>} fn
111
+ * @param {number} limit
112
+ * @returns {Promise<void>}
113
+ */
114
+ async function parallelEach(items, fn, limit) {
115
+ let cursor = 0;
116
+ const workers = Array.from({ length: Math.min(limit, items.length || 1) }, async () => {
117
+ while (cursor < items.length) {
118
+ const i = cursor;
119
+ cursor += 1;
120
+ await fn(items[i], i);
121
+ }
122
+ });
123
+ await Promise.all(workers);
124
+ }
125
+
126
+ /**
127
+ * Validate a rect has the right shape and finite, non-negative dimensions.
128
+ *
129
+ * @param {unknown} rect
130
+ */
131
+ function assertRect(rect) {
132
+ if (rect === null || typeof rect !== 'object') {
133
+ throw new InvalidRectError('rect must be an object', {
134
+ context: { received: typeof rect },
135
+ });
136
+ }
137
+ const { x, y, width, height } = /** @type {Record<string, unknown>} */ (rect);
138
+ for (const [key, value] of [
139
+ ['x', x],
140
+ ['y', y],
141
+ ['width', width],
142
+ ['height', height],
143
+ ]) {
144
+ if (!Number.isFinite(value)) {
145
+ throw new InvalidRectError(`rect.${key} must be a finite number`, {
146
+ context: { rect },
147
+ });
148
+ }
149
+ }
150
+ if (width <= 0 || height <= 0) {
151
+ throw new InvalidRectError('rect.width and rect.height must be > 0', {
152
+ context: { rect },
153
+ });
154
+ }
155
+ }
156
+
157
+ function assertBuffer(input) {
158
+ if (!Buffer.isBuffer(input)) {
159
+ throw new BufferCorruptError('input must be a Buffer', {
160
+ context: { received: typeof input },
161
+ });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Compute the integer rect that sharp.extract expects after applying
167
+ * padding and clamping to image bounds. Padding is symmetric; clamping
168
+ * never throws — we just shrink the requested area.
169
+ *
170
+ * @param {import('../types/index.d.ts').Rect} rect
171
+ * @param {number} padding
172
+ * @param {{width: number, height: number}} meta
173
+ * @returns {{left: number, top: number, width: number, height: number}}
174
+ */
175
+ function computeExtract(rect, padding, meta) {
176
+ const left = clamp(Math.round(rect.x - padding), 0, meta.width);
177
+ const top = clamp(Math.round(rect.y - padding), 0, meta.height);
178
+ const right = clamp(Math.round(rect.x + rect.width + padding), 0, meta.width);
179
+ const bottom = clamp(Math.round(rect.y + rect.height + padding), 0, meta.height);
180
+ return {
181
+ left,
182
+ top,
183
+ width: Math.max(0, right - left),
184
+ height: Math.max(0, bottom - top),
185
+ };
186
+ }
187
+
188
+ function clamp(n, lo, hi) {
189
+ if (n < lo) {
190
+ return lo;
191
+ }
192
+ if (n > hi) {
193
+ return hi;
194
+ }
195
+ return n;
196
+ }
197
+
198
+ /**
199
+ * Read image dimensions via sharp.metadata(). Wraps sharp-thrown errors
200
+ * as BufferCorruptError so callers see the package's error contract.
201
+ *
202
+ * @param {ReturnType<typeof require>} sharp
203
+ * @param {Buffer} input
204
+ * @returns {Promise<{width: number, height: number}>}
205
+ */
206
+ async function readMeta(sharp, input) {
207
+ try {
208
+ const meta = await sharp(input).metadata();
209
+ if (!meta.width || !meta.height) {
210
+ throw new BufferCorruptError('Image has no detectable dimensions', {
211
+ context: { meta },
212
+ });
213
+ }
214
+ return { width: meta.width, height: meta.height };
215
+ } catch (error) {
216
+ if (error instanceof BufferCorruptError) {
217
+ throw error;
218
+ }
219
+ throw new BufferCorruptError('sharp could not read input buffer metadata', {
220
+ cause: error,
221
+ });
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Apply the requested output format to a sharp pipeline. When `format` is
227
+ * undefined the pipeline keeps its native format (input format inferred).
228
+ *
229
+ * @template T
230
+ * @param {T} pipeline
231
+ * @param {string | undefined} format
232
+ * @param {number} quality
233
+ * @returns {T}
234
+ */
235
+ function applyFormat(pipeline, format, quality) {
236
+ if (!format) {
237
+ return pipeline;
238
+ }
239
+ switch (format) {
240
+ case 'png':
241
+ return pipeline.png();
242
+ case 'jpeg':
243
+ return pipeline.jpeg({ quality });
244
+ case 'webp':
245
+ return pipeline.webp({ quality });
246
+ default:
247
+ return pipeline;
248
+ }
249
+ }
250
+
251
+ module.exports = { crop, cropMany };
@@ -0,0 +1,8 @@
1
+ 'use strict';
2
+
3
+ const { crop, cropMany } = require('./crop.js');
4
+ const { convert } = require('./convert.js');
5
+ const { resize } = require('./resize.js');
6
+ const { normalize } = require('./normalize.js');
7
+
8
+ module.exports = { crop, cropMany, convert, resize, normalize };
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ const { BufferCorruptError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Normalize an image buffer to a target device-pixel ratio so its coordinate
7
+ * space is CSS pixels regardless of the source DPR.
8
+ *
9
+ * Implements `spec.md` § 8.2.5. Default behavior preserves the image's
10
+ * intrinsic DPR (caller opts in to normalization for cross-DPR comparison).
11
+ *
12
+ * @public
13
+ * @param {Buffer} input
14
+ * @param {import('../types/index.d.ts').NormalizeOptions} [options]
15
+ * @returns {Promise<import('../types/index.d.ts').NormalizeResult>}
16
+ */
17
+ async function normalize(input, options = {}) {
18
+ assertBuffer(input);
19
+ const sharp = require('sharp');
20
+ const meta = await readMeta(sharp, input);
21
+
22
+ const { fromDpr = inferDpr(meta), toDpr = 1 } = options;
23
+ if (!Number.isFinite(fromDpr) || fromDpr <= 0) {
24
+ throw new BufferCorruptError('Could not infer source DPR and no fromDpr supplied', {
25
+ context: { meta, fromDpr },
26
+ });
27
+ }
28
+ const scale = toDpr / fromDpr;
29
+ if (scale === 1) {
30
+ // No-op: keep the input buffer; report its current CSS dimensions.
31
+ return {
32
+ buffer: input,
33
+ dimensions: {
34
+ width: Math.round(meta.width / fromDpr),
35
+ height: Math.round(meta.height / fromDpr),
36
+ },
37
+ };
38
+ }
39
+ const targetWidth = Math.max(1, Math.round(meta.width * scale));
40
+ const targetHeight = Math.max(1, Math.round(meta.height * scale));
41
+ const buffer = await sharp(input)
42
+ .resize({ width: targetWidth, height: targetHeight })
43
+ .toBuffer();
44
+ return {
45
+ buffer,
46
+ dimensions: { width: targetWidth, height: targetHeight },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Infer the source image's DPR from metadata. Sharp doesn't expose a
52
+ * direct devicePixelRatio so we use the density hint when present
53
+ * (DPI / 96), otherwise assume 1.
54
+ *
55
+ * @param {Awaited<ReturnType<import('sharp').Sharp['metadata']>>} meta
56
+ * @returns {number}
57
+ */
58
+ function inferDpr(meta) {
59
+ if (meta && Number.isFinite(meta.density) && meta.density > 0) {
60
+ return meta.density / 96;
61
+ }
62
+ return 1;
63
+ }
64
+
65
+ function assertBuffer(input) {
66
+ if (!Buffer.isBuffer(input)) {
67
+ throw new BufferCorruptError('input must be a Buffer', {
68
+ context: { received: typeof input },
69
+ });
70
+ }
71
+ }
72
+
73
+ async function readMeta(sharp, input) {
74
+ try {
75
+ return await sharp(input).metadata();
76
+ } catch (error) {
77
+ throw new BufferCorruptError('sharp could not read input buffer metadata', {
78
+ cause: error,
79
+ });
80
+ }
81
+ }
82
+
83
+ module.exports = { normalize };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const { BufferCorruptError, InvalidRectError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Resize an image buffer with sharp-native semantics.
7
+ *
8
+ * Implements `spec.md` § 8.2.3. Honors `width`, `height`, `fit`, `position`,
9
+ * `kernel`, and `withoutEnlargement`. At least one of `width` or `height`
10
+ * must be supplied.
11
+ *
12
+ * @public
13
+ * @param {Buffer} input
14
+ * @param {import('../types/index.d.ts').ResizeOptions} options
15
+ * @returns {Promise<Buffer>}
16
+ */
17
+ async function resize(input, options) {
18
+ assertBuffer(input);
19
+ if (!options || typeof options !== 'object') {
20
+ throw new InvalidRectError('resize requires an options object', {
21
+ context: { received: typeof options },
22
+ });
23
+ }
24
+ const {
25
+ width,
26
+ height,
27
+ fit,
28
+ position,
29
+ kernel,
30
+ withoutEnlargement,
31
+ format,
32
+ quality = 90,
33
+ } = options;
34
+ if (width === undefined && height === undefined) {
35
+ throw new InvalidRectError('resize requires at least one of width or height', {
36
+ context: { options },
37
+ });
38
+ }
39
+ if (width !== undefined && (!Number.isFinite(width) || width <= 0)) {
40
+ throw new InvalidRectError('resize.width must be a positive finite number', {
41
+ context: { width },
42
+ });
43
+ }
44
+ if (height !== undefined && (!Number.isFinite(height) || height <= 0)) {
45
+ throw new InvalidRectError('resize.height must be a positive finite number', {
46
+ context: { height },
47
+ });
48
+ }
49
+ const sharp = require('sharp');
50
+ const resizeOpts = {};
51
+ if (width !== undefined) {
52
+ resizeOpts.width = Math.round(width);
53
+ }
54
+ if (height !== undefined) {
55
+ resizeOpts.height = Math.round(height);
56
+ }
57
+ if (fit) {
58
+ resizeOpts.fit = fit;
59
+ }
60
+ if (position) {
61
+ resizeOpts.position = position;
62
+ }
63
+ if (kernel) {
64
+ resizeOpts.kernel = kernel;
65
+ }
66
+ if (withoutEnlargement !== undefined) {
67
+ resizeOpts.withoutEnlargement = withoutEnlargement;
68
+ }
69
+ let pipeline = sharp(input).resize(resizeOpts);
70
+ switch (format) {
71
+ case 'png':
72
+ pipeline = pipeline.png();
73
+
74
+ break;
75
+
76
+ case 'jpeg':
77
+ pipeline = pipeline.jpeg({ quality });
78
+
79
+ break;
80
+
81
+ case 'webp':
82
+ pipeline = pipeline.webp({ quality });
83
+
84
+ break;
85
+
86
+ // No default
87
+ }
88
+ return pipeline.toBuffer();
89
+ }
90
+
91
+ function assertBuffer(input) {
92
+ if (!Buffer.isBuffer(input)) {
93
+ throw new BufferCorruptError('input must be a Buffer', {
94
+ context: { received: typeof input },
95
+ });
96
+ }
97
+ }
98
+
99
+ module.exports = { resize };