@browserless/screenshot 13.6.3 → 13.6.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/screenshot",
3
3
  "description": "Capture high-quality screenshots of websites with overlay support, device emulation, and automated image optimization.",
4
4
  "homepage": "https://browserless.js.org/#/?id=screenshoturl-options",
5
- "version": "13.6.3",
5
+ "version": "13.6.4",
6
6
  "main": "src/index.js",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@browserless/errors": "13.1.7",
36
- "@browserless/goto": "13.6.3",
36
+ "@browserless/goto": "13.6.4",
37
37
  "@kikobeats/content-type": "~1.0.4",
38
38
  "@kikobeats/time-span": "~1.0.12",
39
39
  "automad-prism-themes": "~0.3.7",
@@ -52,7 +52,7 @@
52
52
  "svg-gradient": "~1.0.4"
53
53
  },
54
54
  "devDependencies": {
55
- "@browserless/test": "13.6.3",
55
+ "@browserless/test": "13.6.4",
56
56
  "ava": "5"
57
57
  },
58
58
  "engines": {
@@ -71,5 +71,5 @@
71
71
  "timeout": "2m",
72
72
  "workerThreads": false
73
73
  },
74
- "gitHead": "7bc1c12120186d999a5ddedbcad4e3b5b52fb8fb"
74
+ "gitHead": "c9d9abe1af82ce0ddf4ce1b6e8fe500c1b58532a"
75
75
  }
@@ -22,14 +22,72 @@ const { isContextDestroyed } = require('@browserless/errors')
22
22
  // inside the viewport, and not hidden via CSS — so a blank shell can't pass for
23
23
  // content. `text` is the equivalent paint signal for imageless pages: visible
24
24
  // characters inside the viewport, counted up to 200 (the threshold consumers
25
- // rely on), and `fonts` reports whether webfonts finished loading during a
26
- // `font-display: block` period text renders invisible, exactly when a capture
27
- // would be white. `viewport` is the in-page viewport height, so consumers can
28
- // compare it against `height` without relying on `page.viewport()`, which is
29
- // null under `defaultViewport: null`.
25
+ // rely on) and skipping text whose color matches its effective background
26
+ // (invisible on capture), and `fonts` reports whether webfonts finished
27
+ // loading during a `font-display: block` period text renders invisible,
28
+ // exactly when a capture would be white. `covered` reports whether the counted
29
+ // content hides behind an opaque viewport-covering layer (a fixed white
30
+ // loading overlay passes every DOM signal while a capture stays white):
31
+ // hit-testing catches interactive overlays, and a root-level scan catches
32
+ // `pointer-events: none` layers hit-testing can't see. `viewport` is the
33
+ // in-page viewport height, so consumers can compare it against `height`
34
+ // without relying on `page.viewport()`, which is null under
35
+ // `defaultViewport: null`.
30
36
  const snapshot = () => {
31
37
  const vw = window.innerWidth || document.documentElement.clientWidth
32
38
  const vh = window.innerHeight || document.documentElement.clientHeight
39
+
40
+ // Computed colors resolve to `rgb()`/`rgba()`; anything else is unknown.
41
+ const parseColor = value => {
42
+ const match = /rgba?\(([^)]+)\)/.exec(value || '')
43
+ if (!match) return null
44
+ const parts = match[1].split(',').map(parseFloat)
45
+ return { r: parts[0], g: parts[1], b: parts[2], a: parts.length === 4 ? parts[3] : 1 }
46
+ }
47
+
48
+ // First opaque background up the ancestor chain; the canvas default (white)
49
+ // when every ancestor is transparent.
50
+ const effectiveBackground = el => {
51
+ for (let node = el; node; node = node.parentElement) {
52
+ const color = parseColor(window.getComputedStyle(node).backgroundColor)
53
+ if (color && color.a >= 0.99) return color
54
+ }
55
+ return { r: 255, g: 255, b: 255, a: 1 }
56
+ }
57
+
58
+ const sameColor = (a, b) =>
59
+ Math.abs(a.r - b.r) < 10 && Math.abs(a.g - b.g) < 10 && Math.abs(a.b - b.b) < 10
60
+
61
+ // A layer only blanks a capture when it is itself painted solid: visible,
62
+ // full opacity, and an opaque background color or a background image.
63
+ const isOpaqueLayer = el => {
64
+ const style = window.getComputedStyle(el)
65
+ if (style.visibility === 'hidden' || parseFloat(style.opacity) < 0.99) return false
66
+ const background = parseColor(style.backgroundColor)
67
+ return (background && background.a >= 0.99) || style.backgroundImage !== 'none'
68
+ }
69
+
70
+ const coversViewport = el => {
71
+ const rect = el.getBoundingClientRect()
72
+ const width = Math.min(rect.right, vw) - Math.max(rect.left, 0)
73
+ const height = Math.min(rect.bottom, vh) - Math.max(rect.top, 0)
74
+ return width > 0 && height > 0 && width * height >= vw * vh * 0.9
75
+ }
76
+
77
+ // Up to a handful of counted content samples — enough to hit-test without
78
+ // turning the snapshot into a layout storm. The clamp keeps each probe point
79
+ // inside the viewport; the rect checks below guarantee it stays inside the
80
+ // sampled content's own box.
81
+ const contentPoints = []
82
+ const samplePoint = (el, rect) => {
83
+ if (contentPoints.length >= 4) return
84
+ contentPoints.push({
85
+ el,
86
+ x: Math.max(0, Math.min(vw - 1, rect.left + rect.width / 2)),
87
+ y: Math.max(0, Math.min(vh - 1, rect.top + rect.height / 2))
88
+ })
89
+ }
90
+
33
91
  const imgs = document.images
34
92
  let images = 0
35
93
  let decoded = 0
@@ -58,6 +116,7 @@ const snapshot = () => {
58
116
  continue
59
117
  }
60
118
  painted++
119
+ samplePoint(img, rect)
61
120
  }
62
121
  // Counting stops at 200 chars, so a text-heavy page costs a handful of nodes,
63
122
  // not a full DOM walk; only a near-blank shell walks every text node.
@@ -86,8 +145,56 @@ const snapshot = () => {
86
145
  const rect = range.getBoundingClientRect()
87
146
  if (rect.width === 0 || rect.height === 0) continue
88
147
  if (rect.bottom <= 0 || rect.right <= 0 || rect.top >= vh || rect.left >= vw) continue
148
+ // White-on-white (or any color-on-same-color) text passes every geometry
149
+ // check while a capture shows nothing: don't count it as painted text.
150
+ const color = parseColor(window.getComputedStyle(el).color)
151
+ if (color && (color.a < 0.05 || sameColor(color, effectiveBackground(el)))) continue
89
152
  text += value.length
153
+ samplePoint(el, rect)
154
+ }
155
+
156
+ // Is this content sample's paint hidden behind an unrelated opaque,
157
+ // viewport-covering layer? Walk up from the hit-tested element: anything
158
+ // related to the sample (itself, an ancestor, a descendant) paints with it,
159
+ // and the walk stops at the first common ancestor — an ancestor's background
160
+ // always paints below its own descendants.
161
+ const coveredAt = ({ el, x, y }) => {
162
+ const top = document.elementFromPoint(x, y)
163
+ if (!top) return false
164
+ for (let node = top; node && node !== document.documentElement; node = node.parentElement) {
165
+ if (node === el || node.contains(el) || el.contains(node)) return false
166
+ if (coversViewport(node) && isOpaqueLayer(node)) return true
167
+ }
168
+ return false
90
169
  }
170
+
171
+ // Only pages the fast path could trust get the (layout-forcing) cover check.
172
+ let covered = false
173
+ if (painted > 0 || text >= 200) {
174
+ covered = contentPoints.some(coveredAt)
175
+ // `elementFromPoint` skips `pointer-events: none` elements, exactly how
176
+ // fading loading overlays are styled — scan root-level layers for one.
177
+ // Overlays mount as direct children of <body>; a deeper scan would cost a
178
+ // full styled DOM walk on every poll for a marginal case.
179
+ if (!covered && document.body) {
180
+ const layers = document.body.children
181
+ for (let i = 0; i < layers.length && !covered; i++) {
182
+ const layer = layers[i]
183
+ const style = window.getComputedStyle(layer)
184
+ if (style.pointerEvents !== 'none') continue
185
+ if (
186
+ style.position !== 'fixed' &&
187
+ style.position !== 'absolute' &&
188
+ style.position !== 'sticky'
189
+ ) {
190
+ continue
191
+ }
192
+ if (contentPoints.some(point => layer.contains(point.el))) continue
193
+ if (coversViewport(layer) && isOpaqueLayer(layer)) covered = true
194
+ }
195
+ }
196
+ }
197
+
91
198
  return {
92
199
  height: document.documentElement.scrollHeight,
93
200
  viewport: vh,
@@ -95,6 +202,7 @@ const snapshot = () => {
95
202
  decoded,
96
203
  painted,
97
204
  text,
205
+ covered,
98
206
  fonts: !document.fonts || document.fonts.status === 'loaded',
99
207
  complete: document.readyState === 'complete'
100
208
  }
@@ -125,6 +233,7 @@ const waitForReady = async (page, { timeout, quietMs = 300, poll = 150 } = {}) =
125
233
  decoded: 0,
126
234
  painted: 0,
127
235
  text: 0,
236
+ covered: false,
128
237
  fonts: false,
129
238
  complete: false
130
239
  }