@browserless/pdf 13.6.8 → 13.6.10

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 (3) hide show
  1. package/README.md +3 -5
  2. package/package.json +4 -4
  3. package/src/index.js +82 -103
package/README.md CHANGED
@@ -73,7 +73,6 @@ The package applies these optimized defaults:
73
73
  | `scale` | `number` | `0.65` | Scale of the webpage rendering (0.1 - 2) |
74
74
  | `printBackground` | `boolean` | `true` | Print background graphics |
75
75
  | `waitUntil` | `string` | `'auto'` | When to consider navigation done |
76
- | `waitForDom` | `number` | `0` | DOM stability window in ms (idle is `waitForDom / 10`, `0` disables DOM wait) |
77
76
  | `format` | `string` | `'Letter'` | Paper format (A4, Letter, etc.) |
78
77
  | `landscape` | `boolean` | `false` | Paper orientation |
79
78
  | `width` | `string \| number` | — | Paper width (overrides format) |
@@ -112,10 +111,9 @@ Supported units: `px`, `in`, `cm`, `mm`
112
111
  When `waitUntil: 'auto'` (the default), the package:
113
112
 
114
113
  1. Navigates to the page
115
- 2. Optionally waits for DOM stability (`waitForDom`, default `0` = disabled)
116
- 3. Takes a low-quality screenshot to check for white/blank content
117
- 4. If the page appears blank, retries until content is detected
118
- 5. Generates the PDF once content is confirmed
114
+ 2. Waits for paint/readiness signals (`waitForReady` + `isPageReady`)
115
+ 3. Hydrates overflow content when ready (`prepareFullDocument`)
116
+ 4. Generates the PDF
119
117
 
120
118
  This ensures dynamic content (JavaScript-rendered pages) is fully loaded before PDF generation.
121
119
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@browserless/pdf",
3
3
  "description": "Convert websites to high-quality PDFs with customizable margins, background printing, and optimized scaling.",
4
4
  "homepage": "https://browserless.js.org/#/?id=pdfurl-options",
5
- "version": "13.6.8",
5
+ "version": "13.6.10",
6
6
  "main": "src",
7
7
  "author": {
8
8
  "email": "hello@microlink.io",
@@ -30,8 +30,8 @@
30
30
  "website-to-pdf"
31
31
  ],
32
32
  "dependencies": {
33
- "@browserless/goto": "13.6.8",
34
- "@browserless/screenshot": "13.6.8",
33
+ "@browserless/goto": "13.6.10",
34
+ "@browserless/screenshot": "13.6.10",
35
35
  "@kikobeats/time-span": "~1.0.12",
36
36
  "debug-logfmt": "~1.4.10",
37
37
  "p-reflect": "~2.1.0",
@@ -47,5 +47,5 @@
47
47
  "test": "exit 0"
48
48
  },
49
49
  "license": "MIT",
50
- "gitHead": "1967ae39bd38666f20239ecc1befd484ed8349e9"
50
+ "gitHead": "0bdae87d4aff5b26de301b2947b70d8ae6c0dcfd"
51
51
  }
package/src/index.js CHANGED
@@ -8,31 +8,23 @@ const pReflect = require('p-reflect')
8
8
  const {
9
9
  captureWithNavigationRetry,
10
10
  isWhiteScreenshot,
11
- waitForDomStability,
12
11
  waitForReady,
13
- resolveWaitForDom,
12
+ prepareFullDocument,
13
+ expandOverflow,
14
+ checkPageReady,
15
+ tryHydrateScroll,
14
16
  SCREENSHOT_DEFAULT_OPTS
15
17
  } = require('@browserless/screenshot')
16
18
 
17
19
  const PDF_DEFAULT_OPTS = {
18
- waitForDom: SCREENSHOT_DEFAULT_OPTS.waitForDom,
19
20
  margin: '0.35cm',
20
21
  scale: 0.65,
21
22
  printBackground: true,
22
- waitUntil: 'auto'
23
+ waitUntil: 'auto',
24
+ isPageReady: SCREENSHOT_DEFAULT_OPTS.isPageReady
23
25
  }
24
26
 
25
- // Share of the phase's load allowance the readiness gate may consume in `auto`
26
- // mode. Pages observed settling in 0.6-3.3s (a hydrating document is the slow
27
- // end), so a quarter of the allowance — ~3.9s at the default request budget —
28
- // covers them with margin while keeping the cap well short of the render's
29
- // share. The gate returns as soon as the page is quiet, so this bounds only a
30
- // page that never settles.
31
27
  const READY_BUDGET_RATIO = 0.25
32
-
33
- // Minimum visible characters for the text fast path. Matches the counting cap
34
- // in `waitForReady`'s snapshot, which stops walking text nodes once reached —
35
- // raising this above the cap would make the text fast path unreachable.
36
28
  const TEXT_PAINTED_MIN = 200
37
29
 
38
30
  const getMargin = unit => {
@@ -46,140 +38,127 @@ const getMargin = unit => {
46
38
  }
47
39
  }
48
40
 
41
+ const isPaintedContent = ({ painted = 0, text = 0, fonts = true } = {}) =>
42
+ painted > 0 || (text >= TEXT_PAINTED_MIN && fonts)
43
+
49
44
  module.exports = ({ goto, ...gotoOpts } = {}) => {
50
45
  goto = goto || createGoto(gotoOpts)
51
46
 
52
- // Render an already-prepared page to a PDF buffer. Split out from the load so
53
- // a single load can be reused across page-range chunks (microlink-api's
54
- // parallel renderer) without re-navigating.
55
- const render = (page, opts = {}) => {
47
+ const render = async (page, opts = {}) => {
56
48
  const {
57
49
  margin = PDF_DEFAULT_OPTS.margin,
58
50
  scale = PDF_DEFAULT_OPTS.scale,
59
51
  printBackground = PDF_DEFAULT_OPTS.printBackground,
60
- waitUntil,
61
- waitForDom,
62
52
  ...rest
63
53
  } = opts
54
+
55
+ await pReflect(expandOverflow(page))
56
+
64
57
  return captureWithNavigationRetry(
65
- () => page.pdf({ ...rest, margin: getMargin(margin), printBackground, scale }),
58
+ () =>
59
+ page.pdf({
60
+ ...rest,
61
+ margin: getMargin(margin),
62
+ printBackground,
63
+ scale
64
+ }),
66
65
  { page, goto, timeout: goto.timeouts.action(rest.timeout) }
67
66
  )
68
67
  }
69
68
 
70
- // Navigate `page` to `url` and wait until it is ready to print: DOM stability
71
- // plus, in `auto` mode, a navigation-tolerant readiness gate. Only a page that
72
- // settles still-blank falls back to the (expensive, navigation-fragile)
73
- // screenshot poll.
74
69
  const prepare = async (page, url, opts = {}) => {
75
70
  const {
76
- margin,
77
- scale,
78
- printBackground,
79
71
  waitUntil = PDF_DEFAULT_OPTS.waitUntil,
80
- waitForDom = PDF_DEFAULT_OPTS.waitForDom,
72
+ isPageReady = PDF_DEFAULT_OPTS.isPageReady,
81
73
  ...rest
82
74
  } = opts
83
- const waitForDomOpts = resolveWaitForDom(waitForDom)
84
-
85
- const waitForDomStabilityResult = async page => {
86
- if (!waitForDomOpts) return
87
-
88
- const result = await pReflect(page.evaluate(waitForDomStability, waitForDomOpts))
89
- debug(
90
- 'waitForDomStability',
91
- result.isRejected
92
- ? { ...waitForDomOpts, error: result.reason.message || result.reason }
93
- : { ...waitForDomOpts, ...result.value }
94
- )
95
- }
96
75
 
97
76
  if (waitUntil !== 'auto') {
98
77
  await goto(page, { ...rest, url, waitUntil })
99
- await waitForDomStabilityResult(page)
78
+ await prepareFullDocument(page, { goto, timeout: rest.timeout })
100
79
  return
101
80
  }
102
81
 
103
- // Surfaced to the caller: a page whose readiness could not be confirmed
104
- // (`timedOut`) is a poor one to keep rendering on, so a caller reusing this
105
- // load across page-ranges can choose a fresh context instead.
106
82
  let readiness
83
+ let isReady = false
84
+ let didHydrateScroll = false
107
85
 
108
86
  await goto(page, { ...rest, url, waitUntil, waitUntilAuto })
87
+
88
+ if (isReady) {
89
+ const prep = await prepareFullDocument(page, {
90
+ goto,
91
+ timeout: rest.timeout,
92
+ scrolled: didHydrateScroll
93
+ })
94
+ readiness = { ...readiness, ...prep }
95
+ }
96
+
109
97
  return readiness
110
98
 
111
- async function waitUntilAuto (page, { timeout: autoTimeout } = {}) {
112
- await waitForDomStabilityResult(page)
113
- const timeout = goto.timeouts.action(rest.timeout)
99
+ async function waitUntilAuto (page, { response, timeout: autoTimeout } = {}) {
100
+ const timeout = autoTimeout ?? goto.timeouts.action(rest.timeout)
101
+ let didHydrateAttempt = false
114
102
 
115
- // The readiness gate waits for a page to settle — page-load work, not a
116
- // small action. Budgeting it from `timeouts.action` (timeout/11) gave it
117
- // ~1.2s while a hydrating document needs 2-3s, so it timed out on every
118
- // tall page and the zero-capture fast path never fired. Budget it from
119
- // the load allowance goto actually assigned to this phase; the gate
120
- // returns as soon as the page is quiet, so this is a cap, not a cost.
121
103
  const readyTime = timeSpan()
122
104
  const ready = await waitForReady(page, {
123
- timeout: Math.round((autoTimeout ?? timeout) * READY_BUDGET_RATIO)
105
+ timeout: Math.round(timeout * READY_BUDGET_RATIO)
124
106
  })
125
107
  readiness = ready
126
108
  debug('ready', { ...ready, duration: readyTime() })
127
109
 
128
- // The blank-page poll keeps its own action budget, measured from here so
129
- // a slow gate cannot starve it.
130
110
  const elapsed = timeSpan()
111
+ const pollTimeout = Math.max(0, timeout - readyTime())
131
112
 
132
- // Fast path: the page settled with real painted content in a document
133
- // taller than the viewport — a visibly rendered image (not a tracking
134
- // pixel), or enough visible text with webfonts loaded (a pending
135
- // `font-display: block` font renders text invisible, exactly when a
136
- // capture would be white) — and that content is not `covered` by an
137
- // opaque viewport-filling layer (a fixed white loading overlay passes
138
- // every other DOM signal while a capture stays white): skip the
139
- // screenshot poll. Height and viewport come from the same in-page
140
- // snapshot (`page.viewport()` is null under `defaultViewport: null`),
141
- // and an unknown viewport skips the fast path rather than dropping the
142
- // taller-than-viewport guard. A gate that timed out never settled, so
143
- // don't trust its partial snapshot: fall through to the blank check.
144
- const painted = ready.painted > 0 || (ready.text >= TEXT_PAINTED_MIN && ready.fonts)
145
- if (
113
+ isReady =
146
114
  !ready.timedOut &&
147
- painted &&
115
+ isPaintedContent(ready) &&
148
116
  !ready.covered &&
149
117
  ready.viewport > 0 &&
150
118
  ready.height > ready.viewport
151
- ) {
152
- return
119
+
120
+ if (isReady) {
121
+ isReady = await checkPageReady(page, { isPageReady, response, isWhite: false })
122
+ if (!isReady) debug('ready:isPageReady', { rejected: true })
123
+ }
124
+
125
+ if (!isReady) {
126
+ let retry = -1
127
+ do {
128
+ ++retry
129
+ const screenshotTime = timeSpan()
130
+ const screenshot = await captureWithNavigationRetry(
131
+ () =>
132
+ page.screenshot({
133
+ optimizeForSpeed: true,
134
+ type: 'jpeg',
135
+ quality: 30
136
+ }),
137
+ { page, goto, timeout: Math.max(0, pollTimeout - elapsed()) }
138
+ )
139
+ const isWhite = await isWhiteScreenshot(screenshot)
140
+ isReady = await checkPageReady(page, { isPageReady, response, screenshot, isWhite })
141
+
142
+ const remaining = pollTimeout - elapsed()
143
+ if (!isReady && !didHydrateAttempt && !isWhite) {
144
+ didHydrateAttempt = true
145
+ const { hydrated, info } = await tryHydrateScroll(page, remaining)
146
+ didHydrateScroll = hydrated
147
+ debug('ready:hydrateScroll', { remaining, hydrated, ...info })
148
+ }
149
+
150
+ if (!isReady) await goto.waitUntilAuto(page, { timeout: rest.timeout })
151
+ debug('retry', {
152
+ waitUntil,
153
+ isReady,
154
+ isWhite,
155
+ retry,
156
+ duration: screenshotTime()
157
+ })
158
+ } while (!isReady && elapsed() < pollTimeout)
153
159
  }
154
160
 
155
- // Otherwise fall back to the screenshot poll — re-wait while the first
156
- // paint is still blank — to keep the blank-SPA protection. The page has
157
- // already settled, so the first capture won't race a navigation, and a
158
- // non-blank page exits after that single shot without an extra re-wait.
159
- let isWhite = false
160
- let retry = -1
161
- do {
162
- ++retry
163
- const screenshotTime = timeSpan()
164
- const screenshot = await captureWithNavigationRetry(
165
- () =>
166
- page.screenshot({
167
- ...rest,
168
- optimizeForSpeed: true,
169
- type: 'jpeg',
170
- quality: 30
171
- }),
172
- // The retry loop keeps its own clock, so hand it only what is left
173
- // of the shared budget — a fresh full `timeout` here would let one
174
- // navigation-racing capture double the worst-case prepare time.
175
- { page, goto, timeout: Math.max(0, timeout - elapsed()) }
176
- )
177
- isWhite = await isWhiteScreenshot(screenshot)
178
- if (isWhite) await goto.waitUntilAuto(page, { timeout: rest.timeout })
179
- debug('retry', { waitUntil, isWhite, retry, duration: screenshotTime() })
180
- } while (isWhite && elapsed() < timeout)
181
-
182
- debug({ waitUntil, isWhite, timeout, duration: require('pretty-ms')(elapsed()) })
161
+ debug({ waitUntil, isReady, timeout: pollTimeout, duration: require('pretty-ms')(elapsed()) })
183
162
  }
184
163
  }
185
164