@browserless/pdf 13.6.8 → 13.6.9
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/README.md +3 -5
- package/package.json +4 -4
- 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.
|
|
116
|
-
3.
|
|
117
|
-
4.
|
|
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.
|
|
5
|
+
"version": "13.6.9",
|
|
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.
|
|
34
|
-
"@browserless/screenshot": "13.6.
|
|
33
|
+
"@browserless/goto": "13.6.9",
|
|
34
|
+
"@browserless/screenshot": "13.6.9",
|
|
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": "
|
|
50
|
+
"gitHead": "c625834b3b37e036432a0756d67eccc149861ad0"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
isPaintedContent(ready) &&
|
|
148
116
|
!ready.covered &&
|
|
149
117
|
ready.viewport > 0 &&
|
|
150
118
|
ready.height > ready.viewport
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|