@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,257 @@
1
+ 'use strict';
2
+
3
+ const { CaptureTimeoutError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Capture a single element by selector or xpath.
7
+ *
8
+ * Implements `spec.md` § 8.1.3. **Deprecated as the engine's primary path**
9
+ * — prefer `capturePage` + `cropMany`. Retained because (a) the engine
10
+ * needs a like-for-like replacement during the Phase 3 migration and
11
+ * (b) direct callers (Investigator, ad-hoc tooling) still want it.
12
+ *
13
+ * Failure modes return an ElementCaptureResult with `bytes === null` and
14
+ * `reason` populated rather than throwing. This matches the engine's
15
+ * current `{ screenshot, reason }` contract so consumers can swap
16
+ * implementations without changing control flow.
17
+ *
18
+ * @public
19
+ * @param {import('../types/index.d.ts').PageAdapter} adapter
20
+ * @param {import('../types/index.d.ts').ElementCaptureOptions} [options]
21
+ * @returns {Promise<import('../types/index.d.ts').ElementCaptureResult>}
22
+ */
23
+ async function captureElement(adapter, options = {}) {
24
+ const {
25
+ selector,
26
+ xpath,
27
+ snippet,
28
+ padding = 10,
29
+ maxDimensions = { width: 4000, height: 4000 },
30
+ scrollIntoView = true,
31
+ timeout = 5000,
32
+ format = 'png',
33
+ quality = 90,
34
+ events = null,
35
+ } = options;
36
+
37
+ if (!selector && !xpath) {
38
+ return failure(
39
+ { selector, xpath, format },
40
+ 'element_not_found',
41
+ null,
42
+ 'No selector or xpath provided'
43
+ );
44
+ }
45
+
46
+ const start = nowMs();
47
+ emit(events, 'capture:start', { format, mode: 'element', selector, xpath });
48
+
49
+ const lookup = await withTimeout(
50
+ adapter.evaluate(findElementInPage, { selector, xpath }),
51
+ timeout,
52
+ { operation: 'adapter.evaluate(findElement)' }
53
+ );
54
+
55
+ if (!lookup || !lookup.found) {
56
+ return failure({ selector, xpath, format }, 'element_not_found', null, null);
57
+ }
58
+ if (lookup.documentRect.width === 0 || lookup.documentRect.height === 0) {
59
+ return failure({ selector, xpath, format }, 'element_not_visible', lookup, null);
60
+ }
61
+ if (
62
+ lookup.documentRect.width > maxDimensions.width ||
63
+ lookup.documentRect.height > maxDimensions.height
64
+ ) {
65
+ return failure({ selector, xpath, format }, 'element_too_large', lookup, null);
66
+ }
67
+ if (snippet) {
68
+ const normalize = s => String(s).replaceAll(/\s+/g, ' ').trim();
69
+ if (normalize(lookup.outerHTML) !== normalize(snippet)) {
70
+ return failure(
71
+ { selector, xpath, format },
72
+ 'element_mismatch',
73
+ lookup,
74
+ 'Provided snippet did not match element outerHTML'
75
+ );
76
+ }
77
+ }
78
+
79
+ let viewportRect = lookup.viewportRect;
80
+ if (scrollIntoView) {
81
+ await adapter.scrollTo({
82
+ x: 0,
83
+ y: Math.max(0, lookup.documentRect.y - padding),
84
+ });
85
+ // Re-evaluate to pick up the new viewport rect after scroll.
86
+ const reread = await withTimeout(
87
+ adapter.evaluate(findElementInPage, { selector, xpath }),
88
+ timeout,
89
+ { operation: 'adapter.evaluate(findElement after scroll)' }
90
+ );
91
+ if (reread && reread.found) {
92
+ viewportRect = reread.viewportRect;
93
+ }
94
+ }
95
+
96
+ const clip = {
97
+ x: Math.max(0, Math.round(viewportRect.x - padding)),
98
+ y: Math.max(0, Math.round(viewportRect.y - padding)),
99
+ width: Math.round(viewportRect.width + 2 * padding),
100
+ height: Math.round(viewportRect.height + 2 * padding),
101
+ };
102
+
103
+ const screenshotOpts = { fullPage: false, type: format, clip };
104
+ if (format === 'jpeg') {
105
+ screenshotOpts.quality = quality;
106
+ }
107
+ const captureStart = nowMs();
108
+ const bytes = await withTimeout(adapter.screenshot(screenshotOpts), timeout, {
109
+ operation: 'adapter.screenshot(element)',
110
+ });
111
+ const captureMs = nowMs() - captureStart;
112
+
113
+ const metrics = await adapter.getMetrics();
114
+ const totalMs = nowMs() - start;
115
+
116
+ const result = {
117
+ format,
118
+ bytes,
119
+ page: metrics,
120
+ timing: { settleMs: 0, captureMs, encodeMs: 0, totalMs },
121
+ warnings: [],
122
+ element: {
123
+ selector: selector ?? '',
124
+ xpath: xpath ?? '',
125
+ documentRect: lookup.documentRect,
126
+ viewportRect,
127
+ verified: Boolean(snippet),
128
+ },
129
+ };
130
+ emit(events, 'capture:complete', { format, totalMs, mode: 'element' });
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * Page-context element lookup. Shipped to the browser via adapter.evaluate.
136
+ * Coverage suppressed for the same reason as `settle.js#runInPage` —
137
+ * runs in the browser, not in Node.
138
+ *
139
+ * @param {{selector?: string, xpath?: string}} args
140
+ * @returns {{found: false} | {found: true, outerHTML: string, documentRect: import('../types/index.d.ts').Rect, viewportRect: import('../types/index.d.ts').Rect}}
141
+ */
142
+ /* v8 ignore start -- runs in the browser via adapter.evaluate(); not
143
+ instrumentable by Node-side coverage. Exercised by the puppeteer/playwright
144
+ element-capture integration tests. */
145
+ function findElementInPage(args) {
146
+ const doc = globalThis.document;
147
+ let element;
148
+ if (args.selector) {
149
+ try {
150
+ element = doc.querySelector(args.selector);
151
+ } catch {
152
+ element = null;
153
+ }
154
+ }
155
+ if (!element && args.xpath) {
156
+ try {
157
+ const FIRST = 9; // XPathResult.FIRST_ORDERED_NODE_TYPE
158
+ const res = doc.evaluate(args.xpath, doc, null, FIRST, null);
159
+ element = res.singleNodeValue;
160
+ } catch {
161
+ element = null;
162
+ }
163
+ }
164
+ if (!element) {
165
+ return { found: false };
166
+ }
167
+ const rect = element.getBoundingClientRect();
168
+ const sx = globalThis.scrollX || 0;
169
+ const sy = globalThis.scrollY || 0;
170
+ return {
171
+ found: true,
172
+ outerHTML: element.outerHTML,
173
+ documentRect: {
174
+ x: rect.x + sx,
175
+ y: rect.y + sy,
176
+ width: rect.width,
177
+ height: rect.height,
178
+ },
179
+ viewportRect: {
180
+ x: rect.x,
181
+ y: rect.y,
182
+ width: rect.width,
183
+ height: rect.height,
184
+ },
185
+ };
186
+ }
187
+ /* v8 ignore stop */
188
+
189
+ /**
190
+ * Build a failure-shaped ElementCaptureResult that preserves the engine's
191
+ * `{ bytes: null, reason }` contract.
192
+ *
193
+ * @param {{selector?: string, xpath?: string, format: string}} input
194
+ * @param {string} reason
195
+ * @param {{outerHTML?: string, documentRect?: import('../types/index.d.ts').Rect, viewportRect?: import('../types/index.d.ts').Rect} | null} lookup
196
+ * @param {string | null} verificationReason
197
+ * @returns {import('../types/index.d.ts').ElementCaptureResult}
198
+ */
199
+ function failure(input, reason, lookup, verificationReason) {
200
+ return {
201
+ format: input.format,
202
+ bytes: null,
203
+ page: null,
204
+ timing: { settleMs: 0, captureMs: 0, encodeMs: 0, totalMs: 0 },
205
+ warnings: [],
206
+ element: {
207
+ selector: input.selector ?? '',
208
+ xpath: input.xpath ?? '',
209
+ documentRect: lookup?.documentRect ?? { x: 0, y: 0, width: 0, height: 0 },
210
+ viewportRect: lookup?.viewportRect ?? { x: 0, y: 0, width: 0, height: 0 },
211
+ verified: false,
212
+ ...(verificationReason ? { verificationReason } : {}),
213
+ },
214
+ reason,
215
+ };
216
+ }
217
+
218
+ function emit(events, name, payload) {
219
+ if (events && typeof events.emit === 'function') {
220
+ events.emit(name, payload);
221
+ }
222
+ }
223
+
224
+ function nowMs() {
225
+ return Number(process.hrtime.bigint() / 1_000_000n);
226
+ }
227
+
228
+ /**
229
+ * Race a promise against a CaptureTimeoutError-throwing timer.
230
+ *
231
+ * @template T
232
+ * @param {Promise<T>} promise
233
+ * @param {number} ms
234
+ * @param {{operation: string}} ctx
235
+ * @returns {Promise<T>}
236
+ */
237
+ function withTimeout(promise, ms, ctx) {
238
+ if (!Number.isFinite(ms) || ms <= 0) {
239
+ return promise;
240
+ }
241
+ let timer;
242
+ const timeoutPromise = new Promise((_resolve, reject) => {
243
+ timer = setTimeout(() => {
244
+ reject(
245
+ new CaptureTimeoutError(`${ctx.operation} exceeded ${ms}ms timeout`, {
246
+ context: { operation: ctx.operation, timeoutMs: ms },
247
+ })
248
+ );
249
+ }, ms);
250
+ if (typeof timer.unref === 'function') {
251
+ timer.unref();
252
+ }
253
+ });
254
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
255
+ }
256
+
257
+ module.exports = { captureElement };
@@ -0,0 +1,17 @@
1
+ 'use strict';
2
+
3
+ const { capturePage } = require('./page.js');
4
+ const { waitForStable } = require('./settle.js');
5
+ const { captureLongPage } = require('./long-page.js');
6
+ const { captureElement } = require('./element.js');
7
+ const { captureViewport } = require('./viewport.js');
8
+ const { captureResponsive } = require('./responsive.js');
9
+
10
+ module.exports = {
11
+ capturePage,
12
+ waitForStable,
13
+ captureLongPage,
14
+ captureElement,
15
+ captureViewport,
16
+ captureResponsive,
17
+ };
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ const { PageTooTallError, CaptureTimeoutError } = require('../errors/index.js');
4
+
5
+ /**
6
+ * Capture a page taller than the browser's single-screenshot height limit
7
+ * by scrolling, capturing strips, and stitching them via `sharp`.
8
+ *
9
+ * Implements `spec.md` § 8.1.4. If the document height fits within the
10
+ * adapter's `screenshotMaxHeight` capability, delegates to the regular
11
+ * `adapter.screenshot({ fullPage: true })`. Otherwise iterates over
12
+ * viewport-height strips, scrolling between captures.
13
+ *
14
+ * @public
15
+ * @param {import('../types/index.d.ts').PageAdapter} adapter
16
+ * @param {import('../types/index.d.ts').LongPageCaptureOptions} [options]
17
+ * @returns {Promise<import('../types/index.d.ts').CaptureResult>}
18
+ */
19
+ async function captureLongPage(adapter, options = {}) {
20
+ const {
21
+ stripHeight = null,
22
+ overlap = 0,
23
+ maxHeight = 32_768,
24
+ format = 'png',
25
+ quality = 90,
26
+ omitBackground = false,
27
+ scrollSettleMs = 100,
28
+ timeout = 30_000,
29
+ events = null,
30
+ } = options;
31
+
32
+ const start = nowMs();
33
+ emit(events, 'capture:start', { format, fullPage: true, mode: 'long-page' });
34
+
35
+ const metrics = await adapter.getMetrics();
36
+ if (metrics.height > maxHeight) {
37
+ throw new PageTooTallError(
38
+ `Page height ${metrics.height}px exceeds maxHeight ${maxHeight}px`,
39
+ { context: { documentHeight: metrics.height, maxHeight } }
40
+ );
41
+ }
42
+
43
+ const capabilities = adapter.capabilities ?? { screenshotMaxHeight: 16_384 };
44
+ const browserMax = capabilities.screenshotMaxHeight;
45
+ const captureStart = nowMs();
46
+
47
+ const { bytes: stitched, encodeMs } =
48
+ metrics.height <= browserMax
49
+ ? await singleShotCapture(adapter, {
50
+ format,
51
+ quality,
52
+ omitBackground,
53
+ timeout,
54
+ })
55
+ : await stripCapture(adapter, {
56
+ metrics,
57
+ stripHeight,
58
+ overlap,
59
+ scrollSettleMs,
60
+ timeout,
61
+ format,
62
+ quality,
63
+ omitBackground,
64
+ events,
65
+ });
66
+
67
+ const captureMs = nowMs() - captureStart - encodeMs;
68
+ const totalMs = nowMs() - start;
69
+
70
+ const result = {
71
+ format,
72
+ bytes: stitched,
73
+ page: metrics,
74
+ timing: { settleMs: 0, captureMs, encodeMs, totalMs },
75
+ warnings: [],
76
+ };
77
+ emit(events, 'capture:complete', { format, totalMs });
78
+ return result;
79
+ }
80
+
81
+ /**
82
+ * Single-shot path: the browser can produce the full document in one call.
83
+ *
84
+ * @param {import('../types/index.d.ts').PageAdapter} adapter
85
+ * @param {{format: import('../types/index.d.ts').Format, quality: number, omitBackground: boolean, timeout: number}} opts
86
+ * @returns {Promise<{bytes: Buffer, encodeMs: number}>}
87
+ */
88
+ async function singleShotCapture(adapter, opts) {
89
+ const { format, quality, omitBackground, timeout } = opts;
90
+ const bytes = await withTimeout(
91
+ adapter.screenshot({
92
+ fullPage: true,
93
+ type: format === 'webp' ? 'png' : format,
94
+ omitBackground,
95
+ ...(format === 'jpeg' ? { quality } : {}),
96
+ }),
97
+ timeout,
98
+ { operation: 'adapter.screenshot(fullPage)' }
99
+ );
100
+ return { bytes, encodeMs: 0 };
101
+ }
102
+
103
+ /**
104
+ * Strip-and-stitch path: scroll the page and capture viewport strips,
105
+ * then composite them via sharp.
106
+ *
107
+ * @param {import('../types/index.d.ts').PageAdapter} adapter
108
+ * @param {{metrics: import('../types/index.d.ts').PageMetrics, stripHeight: number|null, overlap: number, scrollSettleMs: number, timeout: number, format: import('../types/index.d.ts').Format, quality: number, omitBackground: boolean, events: object|null}} opts
109
+ * @returns {Promise<{bytes: Buffer, encodeMs: number}>}
110
+ */
111
+ async function stripCapture(adapter, opts) {
112
+ const {
113
+ metrics,
114
+ stripHeight,
115
+ overlap,
116
+ scrollSettleMs,
117
+ timeout,
118
+ format,
119
+ quality,
120
+ omitBackground,
121
+ events,
122
+ } = opts;
123
+
124
+ const sh = Math.max(1, stripHeight ?? metrics.viewport.height);
125
+ const stride = Math.max(1, sh - overlap);
126
+ const uniqueYs = planStripYs(metrics.height, sh, stride);
127
+
128
+ const strips = [];
129
+ for (let i = 0; i < uniqueYs.length; i += 1) {
130
+ const y = uniqueYs[i];
131
+ await adapter.scrollTo({ x: 0, y });
132
+ if (scrollSettleMs > 0) {
133
+ await sleep(scrollSettleMs);
134
+ }
135
+ const buffer = await withTimeout(
136
+ adapter.screenshot({ fullPage: false, type: 'png', omitBackground }),
137
+ timeout,
138
+ { operation: `adapter.screenshot(strip ${i + 1}/${uniqueYs.length})` }
139
+ );
140
+ strips.push({ y, buffer });
141
+ emit(events, 'strip:captured', { index: i, total: uniqueYs.length, y });
142
+ }
143
+
144
+ const stitchStart = nowMs();
145
+ const bytes = await stitchStrips(strips, {
146
+ width: metrics.viewport.width,
147
+ height: metrics.height,
148
+ format,
149
+ quality,
150
+ });
151
+ return { bytes, encodeMs: nowMs() - stitchStart };
152
+ }
153
+
154
+ /**
155
+ * Compute the scroll-y positions for strip capture. Each strip is `sh` px
156
+ * tall; we advance by `stride` (typically `sh - overlap`). The last strip
157
+ * is anchored at `height - sh` to avoid a partial trailing strip.
158
+ *
159
+ * @param {number} height
160
+ * @param {number} sh strip height
161
+ * @param {number} stride
162
+ * @returns {number[]}
163
+ */
164
+ function planStripYs(height, sh, stride) {
165
+ const ys = [];
166
+ for (let y = 0; y < height; y += stride) {
167
+ ys.push(Math.min(y, height - sh));
168
+ if (y + sh >= height) {
169
+ break;
170
+ }
171
+ }
172
+ return Array.from(new Set(ys));
173
+ }
174
+
175
+ /**
176
+ * Vertically stitch strip buffers into one final image and re-encode to
177
+ * the requested format.
178
+ *
179
+ * @param {Array<{y: number, buffer: Buffer}>} strips
180
+ * @param {{width: number, height: number, format: import('../types/index.d.ts').Format, quality: number}} target
181
+ * @returns {Promise<Buffer>}
182
+ */
183
+ async function stitchStrips(strips, { width, height, format, quality }) {
184
+ const sharp = require('sharp');
185
+ const canvas = sharp({
186
+ create: {
187
+ width: Math.round(width),
188
+ height: Math.round(height),
189
+ channels: 4,
190
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
191
+ },
192
+ }).png();
193
+
194
+ const composites = strips.map(({ y, buffer }) => ({
195
+ input: buffer,
196
+ top: Math.round(y),
197
+ left: 0,
198
+ }));
199
+
200
+ let pipeline = canvas.composite(composites);
201
+ switch (format) {
202
+ case 'webp':
203
+ pipeline = pipeline.webp({ quality });
204
+ break;
205
+ case 'jpeg':
206
+ pipeline = pipeline.jpeg({ quality });
207
+ break;
208
+ case 'png':
209
+ pipeline = pipeline.png();
210
+ break;
211
+ }
212
+ return pipeline.toBuffer();
213
+ }
214
+
215
+ /**
216
+ * Sleep for ms. Used between scroll and capture so the page has time to
217
+ * paint the newly-revealed region.
218
+ *
219
+ * @param {number} ms
220
+ * @returns {Promise<void>}
221
+ */
222
+ function sleep(ms) {
223
+ return new Promise(resolve => {
224
+ setTimeout(resolve, ms);
225
+ });
226
+ }
227
+
228
+ function emit(events, name, payload) {
229
+ if (events && typeof events.emit === 'function') {
230
+ events.emit(name, payload);
231
+ }
232
+ }
233
+
234
+ function nowMs() {
235
+ return Number(process.hrtime.bigint() / 1_000_000n);
236
+ }
237
+
238
+ /**
239
+ * Race a promise against a CaptureTimeoutError-throwing timer.
240
+ *
241
+ * @template T
242
+ * @param {Promise<T>} promise
243
+ * @param {number} ms
244
+ * @param {{operation: string}} ctx
245
+ * @returns {Promise<T>}
246
+ */
247
+ function withTimeout(promise, ms, ctx) {
248
+ if (!Number.isFinite(ms) || ms <= 0) {
249
+ return promise;
250
+ }
251
+ let timer;
252
+ const timeoutPromise = new Promise((_resolve, reject) => {
253
+ timer = setTimeout(() => {
254
+ reject(
255
+ new CaptureTimeoutError(`${ctx.operation} exceeded ${ms}ms timeout`, {
256
+ context: { operation: ctx.operation, timeoutMs: ms },
257
+ })
258
+ );
259
+ }, ms);
260
+ if (typeof timer.unref === 'function') {
261
+ timer.unref();
262
+ }
263
+ });
264
+ return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
265
+ }
266
+
267
+ module.exports = { captureLongPage };