chore: add PDF print for BiDi (#9914)

This commit is contained in:
Nikolay Vitkov 2023-03-27 11:39:40 +02:00 committed by GitHub
parent 94f680a046
commit 95c99e84b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 266 additions and 161 deletions

View File

@ -26,8 +26,6 @@ Promise<Readable>
## Remarks
NOTE: PDF generation is only supported in Chrome headless mode.
To generate a PDF with the `screen` media type, call [\`page.emulateMediaType('screen')\`](./puppeteer.page.emulatemediatype.md) before calling `page.pdf()`.
By default, `page.pdf()` generates a pdf with modified colors for printing. Use the [\`-webkit-print-color-adjust\`](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust) property to force rendering of exact colors.

View File

@ -122,7 +122,7 @@ page.off('request', logRequest);
| [off(eventName, handler)](./puppeteer.page.off.md) | | |
| [on(eventName, handler)](./puppeteer.page.on.md) | | <p>Listen to page events.</p><p>:::note</p><p>This method exists to define event typings and handle proper wireup of cooperative request interception. Actual event listening and dispatching is delegated to [EventEmitter](./puppeteer.eventemitter.md).</p><p>:::</p> |
| [once(eventName, handler)](./puppeteer.page.once.md) | | |
| [pdf(options)](./puppeteer.page.pdf.md) | | |
| [pdf(options)](./puppeteer.page.pdf.md) | | Generates a PDF of the page with the <code>print</code> CSS media type. |
| [queryObjects(prototypeHandle)](./puppeteer.page.queryobjects.md) | | This method iterates the JavaScript heap and finds all objects with the given prototype. |
| [reload(options)](./puppeteer.page.reload.md) | | |
| [screenshot(options)](./puppeteer.page.screenshot.md) | | |

View File

@ -4,6 +4,8 @@ sidebar_label: Page.pdf
# Page.pdf() method
Generates a PDF of the page with the `print` CSS media type.
#### Signature:
```typescript
@ -15,9 +17,15 @@ class Page {
## Parameters
| Parameter | Type | Description |
| --------- | --------------------------------------- | ------------ |
| options | [PDFOptions](./puppeteer.pdfoptions.md) | _(Optional)_ |
| --------- | --------------------------------------- | -------------------------------------------- |
| options | [PDFOptions](./puppeteer.pdfoptions.md) | _(Optional)_ options for generating the PDF. |
**Returns:**
Promise&lt;Buffer&gt;
## Remarks
To generate a PDF with the `screen` media type, call [\`page.emulateMediaType('screen')\`](./puppeteer.page.emulatemediatype.md) before calling `page.pdf()`.
By default, `page.pdf()` generates a pdf with modified colors for printing. Use the [\`-webkit-print-color-adjust\`](https://developer.mozilla.org/en-US/docs/Web/CSS/-webkit-print-color-adjust) property to force rendering of exact colors.

16
package-lock.json generated
View File

@ -2587,9 +2587,9 @@
"license": "ISC"
},
"node_modules/chromium-bidi": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.5.tgz",
"integrity": "sha512-rkav9YzRfAshSTG3wNXF7P7yNiI29QAo1xBXElPoCoSQR5n20q3cOyVhDv6S7+GlF/CJ/emUxlQiR0xOPurkGg==",
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.6.tgz",
"integrity": "sha512-TQOkWRaLI/IWvoP8XC+7jO4uHTIiAUiklXU1T0qszlUFEai9LgKXIBXy3pOS3EnQZ3bQtMbKUPkug0fTAEHCSw==",
"dependencies": {
"mitt": "3.0.0"
},
@ -9481,7 +9481,7 @@
"version": "19.8.0",
"license": "Apache-2.0",
"dependencies": {
"chromium-bidi": "0.4.5",
"chromium-bidi": "0.4.6",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1107588",
@ -11371,9 +11371,9 @@
"version": "1.1.4"
},
"chromium-bidi": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.5.tgz",
"integrity": "sha512-rkav9YzRfAshSTG3wNXF7P7yNiI29QAo1xBXElPoCoSQR5n20q3cOyVhDv6S7+GlF/CJ/emUxlQiR0xOPurkGg==",
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.6.tgz",
"integrity": "sha512-TQOkWRaLI/IWvoP8XC+7jO4uHTIiAUiklXU1T0qszlUFEai9LgKXIBXy3pOS3EnQZ3bQtMbKUPkug0fTAEHCSw==",
"requires": {
"mitt": "3.0.0"
}
@ -14462,7 +14462,7 @@
"puppeteer-core": {
"version": "file:packages/puppeteer-core",
"requires": {
"chromium-bidi": "0.4.5",
"chromium-bidi": "0.4.6",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1107588",

View File

@ -131,7 +131,7 @@
"author": "The Chromium Authors",
"license": "Apache-2.0",
"dependencies": {
"chromium-bidi": "0.4.5",
"chromium-bidi": "0.4.6",
"cross-fetch": "3.1.5",
"debug": "4.3.4",
"devtools-protocol": "0.0.1107588",

View File

@ -43,7 +43,12 @@ import type {
import type {WaitForSelectorOptions} from '../common/IsolatedWorld.js';
import type {PuppeteerLifeCycleEvent} from '../common/LifecycleWatcher.js';
import type {Credentials, NetworkConditions} from '../common/NetworkManager.js';
import type {PDFOptions} from '../common/PDFOptions.js';
import {
LowerCasePaperFormat,
ParsedPDFOptions,
PDFOptions,
paperFormats,
} from '../common/PDFOptions.js';
import type {Viewport} from '../common/PuppeteerViewport.js';
import type {Target} from '../common/Target.js';
import type {Tracing} from '../common/Tracing.js';
@ -53,7 +58,9 @@ import type {
HandleFor,
NodeFor,
} from '../common/types.js';
import {isNumber, isString} from '../common/util.js';
import type {WebWorker} from '../common/WebWorker.js';
import {assert} from '../util/assert.js';
import type {Browser} from './Browser.js';
import type {BrowserContext} from './BrowserContext.js';
@ -2136,12 +2143,58 @@ export class Page extends EventEmitter {
throw new Error('Not implemented');
}
/**
* @internal
*/
_getPDFOptions(options: PDFOptions = {}): ParsedPDFOptions {
const defaults = {
scale: 1,
displayHeaderFooter: false,
headerTemplate: '',
footerTemplate: '',
printBackground: false,
landscape: false,
pageRanges: '',
preferCSSPageSize: false,
omitBackground: false,
timeout: 30000,
};
let width = 8.5;
let height = 11;
if (options.format) {
const format =
paperFormats[options.format.toLowerCase() as LowerCasePaperFormat];
assert(format, 'Unknown paper format: ' + options.format);
width = format.width;
height = format.height;
} else {
width = convertPrintParameterToInches(options.width) ?? width;
height = convertPrintParameterToInches(options.height) ?? height;
}
const margin = {
top: convertPrintParameterToInches(options.margin?.top) || 0,
left: convertPrintParameterToInches(options.margin?.left) || 0,
bottom: convertPrintParameterToInches(options.margin?.bottom) || 0,
right: convertPrintParameterToInches(options.margin?.right) || 0,
};
const output = {
...defaults,
...options,
width,
height,
margin,
};
return output;
}
/**
* Generates a PDF of the page with the `print` CSS media type.
* @remarks
*
* NOTE: PDF generation is only supported in Chrome headless mode.
*
* To generate a PDF with the `screen` media type, call
* {@link Page.emulateMediaType | `page.emulateMediaType('screen')`} before
* calling `page.pdf()`.
@ -2159,8 +2212,7 @@ export class Page extends EventEmitter {
}
/**
* @param options -
* @returns
* {@inheritDoc Page.createPDFStream}
*/
async pdf(options?: PDFOptions): Promise<Buffer>;
async pdf(): Promise<Buffer> {
@ -2619,3 +2671,36 @@ export const unitToPixels = {
cm: 37.8,
mm: 3.78,
};
function convertPrintParameterToInches(
parameter?: string | number
): number | undefined {
if (typeof parameter === 'undefined') {
return undefined;
}
let pixels;
if (isNumber(parameter)) {
// Treat numbers as pixel values to be aligned with phantom's paperSize.
pixels = parameter;
} else if (isString(parameter)) {
const text = parameter;
let unit = text.substring(text.length - 2).toLowerCase();
let valueText = '';
if (unit in unitToPixels) {
valueText = text.substring(0, text.length - 2);
} else {
// In case of unknown unit try to parse the whole parameter as number of pixels.
// This is consistent with phantom's paperSize behavior.
unit = 'px';
valueText = text;
}
const value = Number(valueText);
assert(!isNaN(value), 'Failed to parse parameter value: ' + text);
pixels = value * unitToPixels[unit as keyof typeof unitToPixels];
} else {
throw new Error(
'page.pdf() Cannot handle parameter type: ' + typeof parameter
);
}
return pixels / 96;
}

View File

@ -40,7 +40,7 @@ import {MAIN_WORLD, PUPPETEER_WORLD} from './IsolatedWorlds.js';
import {LazyArg} from './LazyArg.js';
import {LifecycleWatcher, PuppeteerLifeCycleEvent} from './LifecycleWatcher.js';
import {EvaluateFunc, EvaluateFuncWith, HandleFor, NodeFor} from './types.js';
import {importFS} from './util.js';
import {importFSPromises} from './util.js';
/**
* @public
@ -883,9 +883,9 @@ export class Frame {
}
if (path) {
let fs: typeof import('fs').promises;
let fs: typeof import('fs/promises');
try {
fs = (await importFS()).promises;
fs = await importFSPromises();
} catch (error) {
if (error instanceof TypeError) {
throw new Error(

View File

@ -184,10 +184,29 @@ export interface PaperFormatDimensions {
/**
* @internal
*/
export const _paperFormats: Record<
LowerCasePaperFormat,
PaperFormatDimensions
> = {
export interface ParsedPDFOptionsInterface {
width: number;
height: number;
margin: {
top: number;
bottom: number;
left: number;
right: number;
};
}
/**
* @internal
*/
export type ParsedPDFOptions = Required<
Omit<PDFOptions, 'path' | 'format'> & ParsedPDFOptionsInterface
>;
/**
* @internal
*/
export const paperFormats: Record<LowerCasePaperFormat, PaperFormatDimensions> =
{
letter: {width: 8.5, height: 11},
legal: {width: 8.5, height: 14},
tabloid: {width: 11, height: 17},
@ -199,4 +218,4 @@ export const _paperFormats: Record<
a4: {width: 8.27, height: 11.7},
a5: {width: 5.83, height: 8.27},
a6: {width: 4.13, height: 5.83},
} as const;
} as const;

View File

@ -70,7 +70,7 @@ import {
NetworkConditions,
NetworkManagerEmittedEvents,
} from './NetworkManager.js';
import {LowerCasePaperFormat, PDFOptions, _paperFormats} from './PDFOptions.js';
import {PDFOptions} from './PDFOptions.js';
import {Viewport} from './PuppeteerViewport.js';
import {Target} from './Target.js';
import {TargetManagerEmittedEvents} from './TargetManager.js';
@ -91,8 +91,7 @@ import {
getExceptionMessage,
getReadableAsBuffer,
getReadableFromProtocolStream,
importFS,
isNumber,
importFSPromises,
isString,
pageBindingInitString,
releaseObject,
@ -1448,7 +1447,7 @@ export class CDPPage extends Page {
if (options.path) {
try {
const fs = (await importFS()).promises;
const fs = await importFSPromises();
await fs.writeFile(options.path, buffer);
} catch (error) {
if (error instanceof TypeError) {
@ -1471,68 +1470,37 @@ export class CDPPage extends Page {
}
override async createPDFStream(options: PDFOptions = {}): Promise<Readable> {
const {
scale = 1,
displayHeaderFooter = false,
headerTemplate = '',
footerTemplate = '',
printBackground = false,
landscape = false,
pageRanges = '',
preferCSSPageSize = false,
margin = {},
omitBackground = false,
timeout = 30000,
} = options;
const params = this._getPDFOptions(options);
let paperWidth = 8.5;
let paperHeight = 11;
if (options.format) {
const format =
_paperFormats[options.format.toLowerCase() as LowerCasePaperFormat];
assert(format, 'Unknown paper format: ' + options.format);
paperWidth = format.width;
paperHeight = format.height;
} else {
paperWidth = convertPrintParameterToInches(options.width) || paperWidth;
paperHeight =
convertPrintParameterToInches(options.height) || paperHeight;
}
const marginTop = convertPrintParameterToInches(margin.top) || 0;
const marginLeft = convertPrintParameterToInches(margin.left) || 0;
const marginBottom = convertPrintParameterToInches(margin.bottom) || 0;
const marginRight = convertPrintParameterToInches(margin.right) || 0;
if (omitBackground) {
if (params.omitBackground) {
await this.#setTransparentBackgroundColor();
}
const printCommandPromise = this.#client.send('Page.printToPDF', {
transferMode: 'ReturnAsStream',
landscape,
displayHeaderFooter,
headerTemplate,
footerTemplate,
printBackground,
scale,
paperWidth,
paperHeight,
marginTop,
marginBottom,
marginLeft,
marginRight,
pageRanges,
preferCSSPageSize,
landscape: params.landscape,
displayHeaderFooter: params.displayHeaderFooter,
headerTemplate: params.headerTemplate,
footerTemplate: params.footerTemplate,
printBackground: params.printBackground,
scale: params.scale,
paperWidth: params.width,
paperHeight: params.height,
marginTop: params.margin.top,
marginBottom: params.margin.bottom,
marginLeft: params.margin.left,
marginRight: params.margin.right,
pageRanges: params.pageRanges,
preferCSSPageSize: params.preferCSSPageSize,
});
const result = await waitWithTimeout(
printCommandPromise,
'Page.printToPDF',
timeout
params.timeout
);
if (omitBackground) {
if (params.omitBackground) {
await this.#resetDefaultBackgroundColor();
}
@ -1688,43 +1656,3 @@ const supportedMetrics = new Set<string>([
'JSHeapUsedSize',
'JSHeapTotalSize',
]);
const unitToPixels = {
px: 1,
in: 96,
cm: 37.8,
mm: 3.78,
};
function convertPrintParameterToInches(
parameter?: string | number
): number | undefined {
if (typeof parameter === 'undefined') {
return undefined;
}
let pixels;
if (isNumber(parameter)) {
// Treat numbers as pixel values to be aligned with phantom's paperSize.
pixels = parameter;
} else if (isString(parameter)) {
const text = parameter;
let unit = text.substring(text.length - 2).toLowerCase();
let valueText = '';
if (unit in unitToPixels) {
valueText = text.substring(0, text.length - 2);
} else {
// In case of unknown unit try to parse the whole parameter as number of pixels.
// This is consistent with phantom's paperSize behavior.
unit = 'px';
valueText = text;
}
const value = Number(valueText);
assert(!isNaN(value), 'Failed to parse parameter value: ' + text);
pixels = value * unitToPixels[unit as keyof typeof unitToPixels];
} else {
throw new Error(
'page.pdf() Cannot handle parameter type: ' + typeof parameter
);
}
return pixels / 96;
}

View File

@ -55,6 +55,10 @@ interface Commands {
params: Bidi.BrowsingContext.NavigateParameters;
returnType: Bidi.BrowsingContext.NavigateResult;
};
'browsingContext.print': {
params: Bidi.BrowsingContext.PrintParameters;
returnType: Bidi.BrowsingContext.PrintResult;
};
'session.new': {
params: {capabilities?: Record<any, unknown>}; // TODO: Update Types in chromium bidi
@ -152,10 +156,10 @@ export class Connection extends EventEmitter {
#maybeEmitOnContext(event: Bidi.Message.EventMessage) {
let context: Context | undefined;
// Context specific events
if ('context' in event.params) {
if ('context' in event.params && event.params.context) {
context = this.#contexts.get(event.params.context);
// `log.entryAdded` specific context
} else if ('source' in event.params && !!event.params.source.context) {
} else if ('source' in event.params && event.params.source.context) {
context = this.#contexts.get(event.params.source.context);
}
context?.emit(event.method, event.params);

View File

@ -14,6 +14,8 @@
* limitations under the License.
*/
import type {Readable} from 'stream';
import * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {HTTPResponse} from '../../api/HTTPResponse.js';
@ -25,8 +27,9 @@ import {
import {isErrorLike} from '../../util/ErrorLike.js';
import {ConsoleMessage, ConsoleMessageLocation} from '../ConsoleMessage.js';
import {Handler} from '../EventEmitter.js';
import {PDFOptions} from '../PDFOptions.js';
import {EvaluateFunc, HandleFor} from '../types.js';
import {debugError} from '../util.js';
import {debugError, importFSPromises, waitWithTimeout} from '../util.js';
import {Context, getBidiHandle} from './Context.js';
import {BidiSerializer} from './Serializer.js';
@ -199,6 +202,64 @@ export class Page extends PageBase {
return retVal;
});
}
override async pdf(options: PDFOptions = {}): Promise<Buffer> {
const {path = undefined} = options;
const params = this._getPDFOptions(options);
const {result} = await waitWithTimeout(
this.#context.connection.send('browsingContext.print', {
context: this.#context._contextId,
background: params.printBackground,
margin: params.margin,
orientation: params.landscape ? 'landscape' : 'portrait',
page: {
width: params.width,
height: params.height,
},
pageRanges: params.pageRanges.split(', '),
scale: params.scale,
shrinkToFit: !params.preferCSSPageSize,
}),
'browsingContext.print',
params.timeout
);
const buffer = Buffer.from(result.data, 'base64');
try {
if (path) {
const fs = await importFSPromises();
await fs.writeFile(path, buffer);
}
} catch (error) {
if (error instanceof TypeError) {
throw new Error(
'Can only pass a file path in a Node-like environment.'
);
}
throw error;
}
return buffer;
}
override async createPDFStream(
options?: PDFOptions | undefined
): Promise<Readable> {
const buffer = await this.pdf(options);
try {
const {Readable} = await import('stream');
return Readable.from(buffer);
} catch (error) {
if (error instanceof TypeError) {
throw new Error(
'Can only pass a file path in a Node-like environment.'
);
}
throw error;
}
}
}
function isConsoleLogEntry(

View File

@ -361,13 +361,15 @@ export async function waitWithTimeout<T>(
/**
* @internal
*/
let fs: typeof import('fs') | null = null;
let fs: typeof import('fs/promises') | null = null;
/**
* @internal
*/
export async function importFS(): Promise<typeof import('fs')> {
export async function importFSPromises(): Promise<
typeof import('fs/promises')
> {
if (!fs) {
fs = await import('fs');
fs = await import('fs/promises');
}
return fs;
}
@ -381,9 +383,9 @@ export async function getReadableAsBuffer(
): Promise<Buffer | null> {
const buffers = [];
if (path) {
let fs: typeof import('fs').promises;
let fs: typeof import('fs/promises');
try {
fs = (await importFS()).promises;
fs = await importFSPromises();
} catch (error) {
if (error instanceof TypeError) {
throw new Error(

View File

@ -59,6 +59,12 @@
"parameters": ["webDriverBiDi"],
"expectations": ["PASS", "TIMEOUT"]
},
{
"testIdPattern": "[page.spec] Page Page.pdf *",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["PASS"]
},
{
"testIdPattern": "[page.spec] Page Page.setContent *",
"platforms": ["darwin", "linux", "win32"],
@ -297,7 +303,13 @@
"testIdPattern": "[navigation.spec] navigation Page.goto should navigate to URL with hash and fire requests without hash",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[navigation.spec] navigation Page.goto should not leak listeners during navigation of 11 pages",
"platforms": ["darwin"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["PASS", "TIMEOUT"]
},
{
"testIdPattern": "[navigation.spec] navigation Page.goto should not throw an error for a 404 response with an empty body",
@ -315,7 +327,7 @@
"testIdPattern": "[navigation.spec] navigation Page.goto should return last response in redirect chain",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[navigation.spec] navigation Page.goto should return response when page changes its URL after load",
@ -351,7 +363,7 @@
"testIdPattern": "[navigation.spec] navigation Page.goto should work when navigating to valid url",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[navigation.spec] navigation Page.goto should work when page calls history API in beforeunload",
@ -369,7 +381,7 @@
"testIdPattern": "[navigation.spec] navigation Page.reload should work",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"]
"expectations": ["FAIL", "TIMEOUT"]
},
{
"testIdPattern": "[oopif.spec] *",

View File

@ -2051,28 +2051,19 @@ describe('Page', function () {
});
});
describe('printing to PDF', function () {
describe('Page.pdf', function () {
it('can print to PDF and save to file', async () => {
// Printing to pdf is currently only supported in headless
const {isHeadless, page} = getTestState();
if (!isHeadless) {
return;
}
const {page, server} = getTestState();
const outputFile = __dirname + '/../assets/output.pdf';
await page.goto(server.PREFIX + '/pdf.html');
await page.pdf({path: outputFile});
expect(fs.readFileSync(outputFile).byteLength).toBeGreaterThan(0);
fs.unlinkSync(outputFile);
});
it('can print to PDF and stream the result', async () => {
// Printing to pdf is currently only supported in headless
const {isHeadless, page} = getTestState();
if (!isHeadless) {
return;
}
const {page} = getTestState();
const stream = await page.createPDFStream();
let size = 0;
@ -2083,10 +2074,7 @@ describe('Page', function () {
});
it('should respect timeout', async () => {
const {isHeadless, page, server} = getTestState();
if (!isHeadless) {
return;
}
const {page, server} = getTestState();
await page.goto(server.PREFIX + '/pdf.html');