@browserless/pdf 13.6.1 → 13.6.3
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 +4 -4
- package/src/index.js +140 -75
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.3",
|
|
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.3",
|
|
34
|
+
"@browserless/screenshot": "13.6.3",
|
|
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": "7bc1c12120186d999a5ddedbcad4e3b5b52fb8fb"
|
|
51
51
|
}
|
package/src/index.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
const timeSpan = require('@kikobeats/time-span')({ format: n => Math.round(n) })
|
|
4
|
+
const debug = require('debug-logfmt')('browserless:pdf')
|
|
5
|
+
const createGoto = require('@browserless/goto')
|
|
4
6
|
const pReflect = require('p-reflect')
|
|
7
|
+
|
|
5
8
|
const {
|
|
6
9
|
captureWithNavigationRetry,
|
|
7
10
|
isWhiteScreenshot,
|
|
8
11
|
waitForDomStability,
|
|
12
|
+
waitForReady,
|
|
9
13
|
resolveWaitForDom,
|
|
10
|
-
|
|
14
|
+
SCREENSHOT_DEFAULT_OPTS
|
|
11
15
|
} = require('@browserless/screenshot')
|
|
12
|
-
|
|
13
|
-
const
|
|
16
|
+
|
|
17
|
+
const PDF_DEFAULT_OPTS = {
|
|
18
|
+
waitForDom: SCREENSHOT_DEFAULT_OPTS.waitForDom,
|
|
19
|
+
margin: '0.35cm',
|
|
20
|
+
scale: 0.65,
|
|
21
|
+
printBackground: true,
|
|
22
|
+
waitUntil: 'auto'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Share of the action budget the readiness gate may consume in `auto` mode. The
|
|
26
|
+
// remainder is reserved for the blank-SPA screenshot poll to re-wait, so the
|
|
27
|
+
// gate can't starve the fallback while total prepare stays within one `timeout`.
|
|
28
|
+
const READY_BUDGET_RATIO = 0.5
|
|
29
|
+
|
|
30
|
+
// Minimum visible characters for the text fast path. Matches the counting cap
|
|
31
|
+
// in `waitForReady`'s snapshot, which stops walking text nodes once reached —
|
|
32
|
+
// raising this above the cap would make the text fast path unreachable.
|
|
33
|
+
const TEXT_PAINTED_MIN = 200
|
|
14
34
|
|
|
15
35
|
const getMargin = unit => {
|
|
16
36
|
if (!unit) return unit
|
|
@@ -26,83 +46,128 @@ const getMargin = unit => {
|
|
|
26
46
|
module.exports = ({ goto, ...gotoOpts } = {}) => {
|
|
27
47
|
goto = goto || createGoto(gotoOpts)
|
|
28
48
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
// Render an already-prepared page to a PDF buffer. Split out from the load so
|
|
50
|
+
// a single load can be reused across page-range chunks (microlink-api's
|
|
51
|
+
// parallel renderer) without re-navigating.
|
|
52
|
+
const render = (page, opts = {}) => {
|
|
53
|
+
const {
|
|
54
|
+
margin = PDF_DEFAULT_OPTS.margin,
|
|
55
|
+
scale = PDF_DEFAULT_OPTS.scale,
|
|
56
|
+
printBackground = PDF_DEFAULT_OPTS.printBackground,
|
|
57
|
+
waitUntil,
|
|
58
|
+
waitForDom,
|
|
59
|
+
...rest
|
|
60
|
+
} = opts
|
|
61
|
+
return captureWithNavigationRetry(
|
|
62
|
+
() => page.pdf({ ...rest, margin: getMargin(margin), printBackground, scale }),
|
|
63
|
+
{ page, goto, timeout: goto.timeouts.action(rest.timeout) }
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Navigate `page` to `url` and wait until it is ready to print: DOM stability
|
|
68
|
+
// plus, in `auto` mode, a navigation-tolerant readiness gate. Only a page that
|
|
69
|
+
// settles still-blank falls back to the (expensive, navigation-fragile)
|
|
70
|
+
// screenshot poll.
|
|
71
|
+
const prepare = async (page, url, opts = {}) => {
|
|
72
|
+
const {
|
|
73
|
+
margin,
|
|
74
|
+
scale,
|
|
75
|
+
printBackground,
|
|
76
|
+
waitUntil = PDF_DEFAULT_OPTS.waitUntil,
|
|
77
|
+
waitForDom = PDF_DEFAULT_OPTS.waitForDom,
|
|
78
|
+
...rest
|
|
79
|
+
} = opts
|
|
80
|
+
const waitForDomOpts = resolveWaitForDom(waitForDom)
|
|
81
|
+
|
|
82
|
+
const waitForDomStabilityResult = async page => {
|
|
83
|
+
if (!waitForDomOpts) return
|
|
84
|
+
|
|
85
|
+
const result = await pReflect(page.evaluate(waitForDomStability, waitForDomOpts))
|
|
86
|
+
debug(
|
|
87
|
+
'waitForDomStability',
|
|
88
|
+
result.isRejected
|
|
89
|
+
? { ...waitForDomOpts, error: result.reason.message || result.reason }
|
|
90
|
+
: { ...waitForDomOpts, ...result.value }
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (waitUntil !== 'auto') {
|
|
95
|
+
await goto(page, { ...rest, url, waitUntil })
|
|
96
|
+
await waitForDomStabilityResult(page)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await goto(page, { ...rest, url, waitUntil, waitUntilAuto })
|
|
101
|
+
async function waitUntilAuto (page) {
|
|
102
|
+
await waitForDomStabilityResult(page)
|
|
103
|
+
const timeout = goto.timeouts.action(rest.timeout)
|
|
104
|
+
// One action budget shared by the readiness gate and the screenshot poll,
|
|
105
|
+
// so worst-case prepare stays within a single `timeout` instead of one
|
|
106
|
+
// per stage.
|
|
107
|
+
const elapsed = timeSpan()
|
|
108
|
+
|
|
109
|
+
// Cheap, navigation-tolerant readiness — no screenshots. Resolves once the
|
|
110
|
+
// page is visually quiet (height stable, images decoded, load complete),
|
|
111
|
+
// absorbing the client-side re-navigation that makes a screenshot poll
|
|
112
|
+
// throw `Execution context was destroyed`. Capped at a share of the budget
|
|
113
|
+
// so a slow gate still leaves the blank-SPA poll room to re-wait.
|
|
114
|
+
const ready = await waitForReady(page, { timeout: Math.round(timeout * READY_BUDGET_RATIO) })
|
|
115
|
+
debug('ready', { ...ready, duration: elapsed() })
|
|
116
|
+
|
|
117
|
+
// Fast path: the page settled with real painted content in a document
|
|
118
|
+
// taller than the viewport — a visibly rendered image (not a tracking
|
|
119
|
+
// pixel), or enough visible text with webfonts loaded (a pending
|
|
120
|
+
// `font-display: block` font renders text invisible, exactly when a
|
|
121
|
+
// capture would be white) — so it can't be a blank shell: skip the
|
|
122
|
+
// screenshot poll. Height and viewport come from the same in-page
|
|
123
|
+
// snapshot (`page.viewport()` is null under `defaultViewport: null`),
|
|
124
|
+
// and an unknown viewport skips the fast path rather than dropping the
|
|
125
|
+
// taller-than-viewport guard. A gate that timed out never settled, so
|
|
126
|
+
// don't trust its partial snapshot: fall through to the blank check.
|
|
127
|
+
const painted = ready.painted > 0 || (ready.text >= TEXT_PAINTED_MIN && ready.fonts)
|
|
128
|
+
if (!ready.timedOut && painted && ready.viewport > 0 && ready.height > ready.viewport) return
|
|
129
|
+
|
|
130
|
+
// Otherwise fall back to the screenshot poll — re-wait while the first
|
|
131
|
+
// paint is still blank — to keep the blank-SPA protection. The page has
|
|
132
|
+
// already settled, so the first capture won't race a navigation, and a
|
|
133
|
+
// non-blank page exits after that single shot without an extra re-wait.
|
|
134
|
+
let isWhite = false
|
|
135
|
+
let retry = -1
|
|
136
|
+
do {
|
|
137
|
+
++retry
|
|
138
|
+
const screenshotTime = timeSpan()
|
|
139
|
+
const screenshot = await captureWithNavigationRetry(
|
|
46
140
|
() =>
|
|
47
|
-
page.
|
|
48
|
-
...
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
141
|
+
page.screenshot({
|
|
142
|
+
...rest,
|
|
143
|
+
optimizeForSpeed: true,
|
|
144
|
+
type: 'jpeg',
|
|
145
|
+
quality: 30
|
|
52
146
|
}),
|
|
53
|
-
|
|
147
|
+
// The retry loop keeps its own clock, so hand it only what is left
|
|
148
|
+
// of the shared budget — a fresh full `timeout` here would let one
|
|
149
|
+
// navigation-racing capture double the worst-case prepare time.
|
|
150
|
+
{ page, goto, timeout: Math.max(0, timeout - elapsed()) }
|
|
54
151
|
)
|
|
152
|
+
isWhite = await isWhiteScreenshot(screenshot)
|
|
153
|
+
if (isWhite) await goto.waitUntilAuto(page, { timeout: rest.timeout })
|
|
154
|
+
debug('retry', { waitUntil, isWhite, retry, duration: screenshotTime() })
|
|
155
|
+
} while (isWhite && elapsed() < timeout)
|
|
55
156
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const result = await pReflect(page.evaluate(waitForDomStability, waitForDomOpts))
|
|
60
|
-
debug(
|
|
61
|
-
'waitForDomStability',
|
|
62
|
-
result.isRejected
|
|
63
|
-
? { ...waitForDomOpts, error: result.reason.message || result.reason }
|
|
64
|
-
: { ...waitForDomOpts, ...result.value }
|
|
65
|
-
)
|
|
66
|
-
}
|
|
157
|
+
debug({ waitUntil, isWhite, timeout, duration: require('pretty-ms')(elapsed()) })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
67
160
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await goto(page, { ...opts, url, waitUntil, waitUntilAuto })
|
|
74
|
-
async function waitUntilAuto (page) {
|
|
75
|
-
await waitForDomStabilityResult(page)
|
|
76
|
-
const timeout = goto.timeouts.action(opts.timeout)
|
|
77
|
-
let isWhite = false
|
|
78
|
-
let retry = -1
|
|
79
|
-
|
|
80
|
-
const timePdf = timeSpan()
|
|
81
|
-
|
|
82
|
-
do {
|
|
83
|
-
++retry
|
|
84
|
-
const screenshotTime = timeSpan()
|
|
85
|
-
const screenshot = await captureWithNavigationRetry(
|
|
86
|
-
() =>
|
|
87
|
-
page.screenshot({
|
|
88
|
-
...opts,
|
|
89
|
-
optimizeForSpeed: true,
|
|
90
|
-
type: 'jpeg',
|
|
91
|
-
quality: 30
|
|
92
|
-
}),
|
|
93
|
-
{ page, goto, timeout }
|
|
94
|
-
)
|
|
95
|
-
isWhite = await isWhiteScreenshot(screenshot)
|
|
96
|
-
if (isWhite) await goto.waitUntilAuto(page, { timeout: opts.timeout })
|
|
97
|
-
debug('retry', { waitUntil, isWhite, retry, duration: screenshotTime() })
|
|
98
|
-
} while (isWhite && timePdf() < timeout)
|
|
99
|
-
|
|
100
|
-
debug({ waitUntil, isWhite, timeout, duration: require('pretty-ms')(timePdf()) })
|
|
101
|
-
}
|
|
102
|
-
pdfBuffer = await generatePdf(page)
|
|
161
|
+
const pdf =
|
|
162
|
+
page =>
|
|
163
|
+
async (url, opts = {}) => {
|
|
164
|
+
await prepare(page, url, opts)
|
|
165
|
+
return render(page, opts)
|
|
103
166
|
}
|
|
104
167
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
168
|
+
pdf.prepare = prepare
|
|
169
|
+
pdf.render = render
|
|
170
|
+
return pdf
|
|
108
171
|
}
|
|
172
|
+
|
|
173
|
+
module.exports.DEFAULT_OPTS = PDF_DEFAULT_OPTS
|