@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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. 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.1",
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.1",
34
- "@browserless/screenshot": "13.6.1",
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": "b3d84200cc13b038b1b255e0895e20e6a0683fd6"
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
- DEFAULT_WAIT_FOR_DOM
14
+ SCREENSHOT_DEFAULT_OPTS
11
15
  } = require('@browserless/screenshot')
12
- const debug = require('debug-logfmt')('browserless:pdf')
13
- const createGoto = require('@browserless/goto')
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
- return function pdf (page) {
30
- return async (
31
- url,
32
- {
33
- margin = '0.35cm',
34
- scale = 0.65,
35
- printBackground = true,
36
- waitUntil = 'auto',
37
- waitForDom = DEFAULT_WAIT_FOR_DOM,
38
- ...opts
39
- } = {}
40
- ) => {
41
- let pdfBuffer
42
- const waitForDomOpts = resolveWaitForDom(waitForDom)
43
-
44
- const generatePdf = page =>
45
- captureWithNavigationRetry(
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.pdf({
48
- ...opts,
49
- margin: getMargin(margin),
50
- printBackground,
51
- scale
141
+ page.screenshot({
142
+ ...rest,
143
+ optimizeForSpeed: true,
144
+ type: 'jpeg',
145
+ quality: 30
52
146
  }),
53
- { page, goto, timeout: goto.timeouts.action(opts.timeout) }
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
- const waitForDomStabilityResult = async page => {
57
- if (!waitForDomOpts) return
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
- if (waitUntil !== 'auto') {
69
- await goto(page, { ...opts, url, waitUntil })
70
- await waitForDomStabilityResult(page)
71
- pdfBuffer = await generatePdf(page)
72
- } else {
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
- return pdfBuffer
106
- }
107
- }
168
+ pdf.prepare = prepare
169
+ pdf.render = render
170
+ return pdf
108
171
  }
172
+
173
+ module.exports.DEFAULT_OPTS = PDF_DEFAULT_OPTS