diff --git a/docs/api.md b/docs/api.md index 51f638f985..7cf55959c6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -120,6 +120,7 @@ * [page.mainFrame()](#pagemainframe) * [page.metrics()](#pagemetrics) * [page.mouse](#pagemouse) + * [page.pdf](#pagepdf) * [page.queryObjects(prototypeHandle)](#pagequeryobjectsprototypehandle) * [page.reload([options])](#pagereloadoptions) * [page.screenshot([options])](#pagescreenshotoptions) @@ -175,6 +176,8 @@ * [mouse.move(x, y[, options])](#mousemovex-y-options) * [mouse.tripleclick(x, y[, options])](#mousetripleclickx-y-options) * [mouse.up([options])](#mouseupoptions) +- [class: PDF](#class-pdf) + * [pdf.generate([options])](#pdfgenerateoptions) - [class: Touchscreen](#class-touchscreen) * [touchscreen.tap(x, y)](#touchscreentapx-y) - [class: Tracing](#class-tracing) @@ -1656,6 +1659,9 @@ Page is guaranteed to have a main frame which persists during navigations. - returns: <[Mouse]> +#### page.pdf +- returns: <[PDF]> + #### page.queryObjects(prototypeHandle) - `prototypeHandle` <[JSHandle]> A handle to the object prototype. - returns: <[Promise]<[JSHandle]>> Promise which resolves to a handle to an array of objects with this prototype. @@ -2459,6 +2465,77 @@ Shortcut for [`mouse.move`](#mousemovex-y-options), [`mouse.down`](#mousedownopt Dispatches a `mouseup` event. +### class: PDF + +#### pdf.generate([options]) +- `options` <[Object]> Options object which might have the following properties: + - `path` <[string]> The file path to save the PDF to. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the PDF won't be saved to the disk. + - `scale` <[number]> Scale of the webpage rendering. Defaults to `1`. Scale amount must be between 0.1 and 2. + - `displayHeaderFooter` <[boolean]> Display header and footer. Defaults to `false`. + - `headerTemplate` <[string]> HTML template for the print header. Should be valid HTML markup with following classes used to inject printing values into them: + - `date` formatted print date + - `title` document title + - `url` document location + - `pageNumber` current page number + - `totalPages` total pages in the document + - `footerTemplate` <[string]> HTML template for the print footer. Should use the same format as the `headerTemplate`. + - `printBackground` <[boolean]> Print background graphics. Defaults to `false`. + - `landscape` <[boolean]> Paper orientation. Defaults to `false`. + - `pageRanges` <[string]> Paper ranges to print, e.g., '1-5, 8, 11-13'. Defaults to the empty string, which means print all pages. + - `format` <[string]> Paper format. If set, takes priority over `width` or `height` options. Defaults to 'Letter'. + - `width` <[string]|[number]> Paper width, accepts values labeled with units. + - `height` <[string]|[number]> Paper height, accepts values labeled with units. + - `margin` <[Object]> Paper margins, defaults to none. + - `top` <[string]|[number]> Top margin, accepts values labeled with units. + - `right` <[string]|[number]> Right margin, accepts values labeled with units. + - `bottom` <[string]|[number]> Bottom margin, accepts values labeled with units. + - `left` <[string]|[number]> Left margin, accepts values labeled with units. + - `preferCSSPageSize` <[boolean]> Give any CSS `@page` size declared in the page priority over what is declared in `width` and `height` or `format` options. Defaults to `false`, which will scale the content to fit the paper size. +- returns: <[Promise]<[Buffer]>> Promise which resolves with PDF buffer. + +> **NOTE** Generating a pdf is currently only supported in Chrome headless. + +`page.pdf()` generates a pdf of the page with `print` css media. To generate a pdf with `screen` media, call [page.emulateMedia('screen')](#pageemulatemediamediatype) before calling `page.pdf()`: + +> **NOTE** 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. + +```js +// Generates a PDF with 'screen' media type. +await page.emulateMedia('screen'); +await page.pdf({path: 'page.pdf'}); +``` + +The `width`, `height`, and `margin` options accept values labeled with units. Unlabeled values are treated as pixels. + +A few examples: +- `page.pdf({width: 100})` - prints with width set to 100 pixels +- `page.pdf({width: '100px'})` - prints with width set to 100 pixels +- `page.pdf({width: '10cm'})` - prints with width set to 10 centimeters. + +All possible units are: +- `px` - pixel +- `in` - inch +- `cm` - centimeter +- `mm` - millimeter + +The `format` options are: +- `Letter`: 8.5in x 11in +- `Legal`: 8.5in x 14in +- `Tabloid`: 11in x 17in +- `Ledger`: 17in x 11in +- `A0`: 33.1in x 46.8in +- `A1`: 23.4in x 33.1in +- `A2`: 16.54in x 23.4in +- `A3`: 11.7in x 16.54in +- `A4`: 8.27in x 11.7in +- `A5`: 5.83in x 8.27in +- `A6`: 4.13in x 5.83in + +> **NOTE** `headerTemplate` and `footerTemplate` markup have the following limitations: +> 1. Script tags inside templates are not evaluated. +> 2. Page styles are not visible inside templates. + + ### class: Touchscreen #### touchscreen.tap(x, y) diff --git a/src/api.ts b/src/api.ts index db511c21fe..4eb787f4e0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -16,13 +16,13 @@ */ export = { Chromium: { - Accessibility: require('./chromium/Accessibility').Accessibility, + Accessibility: require('./chromium/features/accessibility').Accessibility, Browser: require('./chromium/Browser').Browser, BrowserContext: require('./chromium/BrowserContext').BrowserContext, BrowserFetcher: require('./chromium/BrowserFetcher').BrowserFetcher, CDPSession: require('./chromium/Connection').CDPSession, ConsoleMessage: require('./chromium/Page').ConsoleMessage, - Coverage: require('./chromium/Coverage').Coverage, + Coverage: require('./chromium/features/coverage').Coverage, Dialog: require('./chromium/Dialog').Dialog, ElementHandle: require('./chromium/JSHandle').ElementHandle, ExecutionContext: require('./chromium/ExecutionContext').ExecutionContext, @@ -31,6 +31,7 @@ export = { JSHandle: require('./chromium/JSHandle').JSHandle, Keyboard: require('./chromium/Input').Keyboard, Mouse: require('./chromium/Input').Mouse, + PDF: require('./chromium/features/pdf').PDF, Page: require('./chromium/Page').Page, Playwright: require('./chromium/Playwright').Playwright, Request: require('./chromium/NetworkManager').Request, @@ -39,7 +40,7 @@ export = { Target: require('./chromium/Target').Target, TimeoutError: require('./Errors').TimeoutError, Touchscreen: require('./chromium/Input').Touchscreen, - Tracing: require('./chromium/Tracing').Tracing, + Tracing: require('./chromium/features/tracing').Tracing, Worker: require('./chromium/Worker').Worker, }, Firefox: { diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index ae6a1ae7e5..0eef755716 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -19,27 +19,28 @@ import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as mime from 'mime'; import * as path from 'path'; -import { Accessibility } from './Accessibility'; -import { CDPSession, CDPSessionEvents, Connection } from './Connection'; -import { Coverage } from './Coverage'; -import { Dialog, DialogType } from './Dialog'; -import { EmulationManager } from './EmulationManager'; import { Events } from '../Events'; -import { Frame } from './Frame'; -import { FrameManager, FrameManagerEvents } from './FrameManager'; import { assert, debugError, helper } from '../helper'; -import { releaseObject, getExceptionMessage, valueFromRemoteObject } from './protocolHelper'; -import { Keyboard, Mouse, Touchscreen } from './Input'; -import { createJSHandle, ElementHandle, JSHandle, ClickOptions, PointerActionOptions, MultiClickOptions } from './JSHandle'; -import { Response, NetworkManagerEvents } from './NetworkManager'; -import { TaskQueue } from './TaskQueue'; import { TimeoutSettings } from '../TimeoutSettings'; -import { Tracing } from './Tracing'; -import { Worker } from './Worker'; -import { Target } from './Target'; +import { Accessibility } from './features/accessibility'; import { Browser } from './Browser'; import { BrowserContext } from './BrowserContext'; +import { CDPSession, CDPSessionEvents, Connection } from './Connection'; +import { Coverage } from './features/coverage'; +import { Dialog, DialogType } from './Dialog'; +import { EmulationManager } from './EmulationManager'; +import { PDF } from './features/pdf'; +import { Frame } from './Frame'; +import { FrameManager, FrameManagerEvents } from './FrameManager'; +import { Keyboard, Mouse, Touchscreen } from './Input'; +import { ClickOptions, createJSHandle, ElementHandle, JSHandle, MultiClickOptions, PointerActionOptions } from './JSHandle'; +import { NetworkManagerEvents, Response } from './NetworkManager'; import { Protocol } from './protocol'; +import { getExceptionMessage, releaseObject, valueFromRemoteObject } from './protocolHelper'; +import { Target } from './Target'; +import { TaskQueue } from './TaskQueue'; +import { Tracing } from './features/tracing'; +import { Worker } from './Worker'; const writeFileAsync = helper.promisify(fs.writeFile); @@ -60,12 +61,13 @@ export class Page extends EventEmitter { private _mouse: Mouse; private _timeoutSettings: TimeoutSettings; private _touchscreen: Touchscreen; - private _accessibility: Accessibility; private _frameManager: FrameManager; private _emulationManager: EmulationManager; - private _tracing: Tracing; + readonly accessibility: Accessibility; + readonly coverage: Coverage; + readonly pdf: PDF; + readonly tracing: Tracing; private _pageBindings = new Map(); - private _coverage: Coverage; _javascriptEnabled = true; private _viewport: Viewport | null = null; private _screenshotTaskQueue: TaskQueue; @@ -90,11 +92,12 @@ export class Page extends EventEmitter { this._mouse = new Mouse(client, this._keyboard); this._timeoutSettings = new TimeoutSettings(); this._touchscreen = new Touchscreen(client, this._keyboard); - this._accessibility = new Accessibility(client); + this.accessibility = new Accessibility(client); this._frameManager = new FrameManager(client, this, ignoreHTTPSErrors, this._timeoutSettings); this._emulationManager = new EmulationManager(client); - this._tracing = new Tracing(client); - this._coverage = new Coverage(client); + this.tracing = new Tracing(client); + this.coverage = new Coverage(client); + this.pdf = new PDF(client); this._screenshotTaskQueue = screenshotTaskQueue; @@ -231,18 +234,6 @@ export class Page extends EventEmitter { return this._touchscreen; } - get coverage(): Coverage { - return this._coverage; - } - - get tracing(): Tracing { - return this._tracing; - } - - get accessibility(): Accessibility { - return this._accessibility; - } - frames(): Frame[] { return this._frameManager.frames(); } diff --git a/test/accessibility.spec.js b/src/chromium/features/accessibility.spec.js similarity index 100% rename from test/accessibility.spec.js rename to src/chromium/features/accessibility.spec.js diff --git a/src/chromium/Accessibility.ts b/src/chromium/features/accessibility.ts similarity index 98% rename from src/chromium/Accessibility.ts rename to src/chromium/features/accessibility.ts index abcd6e9dbf..dfb15d02c5 100644 --- a/src/chromium/Accessibility.ts +++ b/src/chromium/features/accessibility.ts @@ -15,9 +15,9 @@ * limitations under the License. */ -import { CDPSession } from './Connection'; -import { ElementHandle } from './JSHandle'; -import { Protocol } from './protocol'; +import { CDPSession } from '../Connection'; +import { ElementHandle } from '../JSHandle'; +import { Protocol } from '../protocol'; type SerializedAXNode = { role: string, diff --git a/test/coverage.spec.js b/src/chromium/features/coverage.spec.js similarity index 100% rename from test/coverage.spec.js rename to src/chromium/features/coverage.spec.js diff --git a/src/chromium/Coverage.ts b/src/chromium/features/coverage.ts similarity index 98% rename from src/chromium/Coverage.ts rename to src/chromium/features/coverage.ts index 8e705ccb1e..34846d1f3a 100644 --- a/src/chromium/Coverage.ts +++ b/src/chromium/features/coverage.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import { CDPSession } from './Connection'; -import { assert, debugError, helper, RegisteredListener } from '../helper'; -import { Protocol } from './protocol'; +import { CDPSession } from '../Connection'; +import { assert, debugError, helper, RegisteredListener } from '../../helper'; +import { Protocol } from '../protocol'; -const {EVALUATION_SCRIPT_URL} = require('./ExecutionContext'); +import { EVALUATION_SCRIPT_URL } from '../ExecutionContext'; type CoverageEntry = { url: string, diff --git a/src/chromium/features/pdf.spec.js b/src/chromium/features/pdf.spec.js new file mode 100644 index 0000000000..fb8119f29a --- /dev/null +++ b/src/chromium/features/pdf.spec.js @@ -0,0 +1,34 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const path = require('path'); + +module.exports.addTests = function({testRunner, expect, headless, ASSETS_DIR}) { + const {describe, xdescribe, fdescribe} = testRunner; + const {it, fit, xit} = testRunner; + const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; + + // Printing to pdf is currently only supported in headless + describe.skip(!headless)('Page.pdf', function() { + it('should be able to save file', async({page, server}) => { + const outputFile = path.join(ASSETS_DIR, 'output.pdf'); + await page.pdf.generate({path: outputFile}); + expect(fs.readFileSync(outputFile).byteLength).toBeGreaterThan(0); + fs.unlinkSync(outputFile); + }); + }); +}; diff --git a/src/chromium/features/pdf.ts b/src/chromium/features/pdf.ts new file mode 100644 index 0000000000..0fc72972fe --- /dev/null +++ b/src/chromium/features/pdf.ts @@ -0,0 +1,144 @@ +/** + * Copyright 2017 Google Inc. All rights reserved. + * Modifications copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert, helper } from '../../helper'; +import { CDPSession } from '../Connection'; +import { readProtocolStream } from '../protocolHelper'; + +type PDFOptions = { + scale?: number, + displayHeaderFooter?: boolean, + headerTemplate?: string, + footerTemplate?: string, + printBackground?: boolean, + landscape?: boolean, + pageRanges?: string, + format?: string, + width?: string|number, + height?: string|number, + preferCSSPageSize?: boolean, + margin?: {top?: string|number, bottom?: string|number, left?: string|number, right?: string|number}, + path?: string, +} + +const PagePaperFormats = { + letter: {width: 8.5, height: 11}, + legal: {width: 8.5, height: 14}, + tabloid: {width: 11, height: 17}, + ledger: {width: 17, height: 11}, + a0: {width: 33.1, height: 46.8 }, + a1: {width: 23.4, height: 33.1 }, + a2: {width: 16.54, height: 23.4 }, + a3: {width: 11.7, height: 16.54 }, + a4: {width: 8.27, height: 11.7 }, + a5: {width: 5.83, height: 8.27 }, + a6: {width: 4.13, height: 5.83 }, +}; + +const unitToPixels = { + 'px': 1, + 'in': 96, + 'cm': 37.8, + 'mm': 3.78 +}; + +function convertPrintParameterToInches(parameter: (string | number | undefined)): (number | undefined) { + if (typeof parameter === 'undefined') + return undefined; + let pixels: number; + if (helper.isNumber(parameter)) { + // Treat numbers as pixel values to be aligned with phantom's paperSize. + pixels = parameter as number; + } else if (helper.isString(parameter)) { + const text: string = parameter as string; + let unit = text.substring(text.length - 2).toLowerCase(); + let valueText = ''; + if (unitToPixels.hasOwnProperty(unit)) { + 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]; + } else { + throw new Error('page.pdf() Cannot handle parameter type: ' + (typeof parameter)); + } + return pixels / 96; +} + +export class PDF { + private _client: CDPSession; + + constructor(client: CDPSession) { + this._client = client; + } + + async generate(options: PDFOptions = {}): Promise { + const { + scale = 1, + displayHeaderFooter = false, + headerTemplate = '', + footerTemplate = '', + printBackground = false, + landscape = false, + pageRanges = '', + preferCSSPageSize = false, + margin = {}, + path = null + } = options; + + let paperWidth = 8.5; + let paperHeight = 11; + if (options.format) { + const format = PagePaperFormats[options.format.toLowerCase()]; + 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; + + const result = await this._client.send('Page.printToPDF', { + transferMode: 'ReturnAsStream', + landscape, + displayHeaderFooter, + headerTemplate, + footerTemplate, + printBackground, + scale, + paperWidth, + paperHeight, + marginTop, + marginBottom, + marginLeft, + marginRight, + pageRanges, + preferCSSPageSize + }); + return await readProtocolStream(this._client, result.stream, path); + } +} diff --git a/test/tracing.spec.js b/src/chromium/features/tracing.spec.js similarity index 94% rename from test/tracing.spec.js rename to src/chromium/features/tracing.spec.js index e319e92bdd..86cd7288fa 100644 --- a/test/tracing.spec.js +++ b/src/chromium/features/tracing.spec.js @@ -17,14 +17,14 @@ const fs = require('fs'); const path = require('path'); -module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, playwright, FFOX, CHROME, WEBKIT}) { +module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, playwright, ASSETS_DIR}) { const {describe, xdescribe, fdescribe} = testRunner; const {it, fit, xit} = testRunner; const {beforeAll, beforeEach, afterAll, afterEach} = testRunner; describe('Tracing', function() { beforeEach(async function(state) { - state.outputFile = path.join(__dirname, 'assets', `trace-${state.parallelIndex}.json`); + state.outputFile = path.join(ASSETS_DIR, `trace-${state.parallelIndex}.json`); state.browser = await playwright.launch(defaultBrowserOptions); state.page = await state.browser.newPage(); }); @@ -47,7 +47,7 @@ module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, p await page.tracing.start({path: outputFile, categories: ['disabled-by-default-v8.cpu_profiler.hires']}); await page.tracing.stop(); - const traceJson = JSON.parse(fs.readFileSync(outputFile)); + const traceJson = JSON.parse(fs.readFileSync(outputFile).toString()); expect(traceJson.metadata['trace-config']).toContain('disabled-by-default-v8.cpu_profiler.hires'); }); it('should throw if tracing on two pages', async({page, server, browser, outputFile}) => { diff --git a/src/chromium/Tracing.ts b/src/chromium/features/tracing.ts similarity index 94% rename from src/chromium/Tracing.ts rename to src/chromium/features/tracing.ts index 7b908ac8a0..1adc690d21 100644 --- a/src/chromium/Tracing.ts +++ b/src/chromium/features/tracing.ts @@ -14,9 +14,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { CDPSession } from './Connection'; -import { assert } from '../helper'; -import { readProtocolStream } from './protocolHelper'; +import { CDPSession } from '../Connection'; +import { assert } from '../../helper'; +import { readProtocolStream } from '../protocolHelper'; export class Tracing { private _client: CDPSession; diff --git a/test/playwright.spec.js b/test/playwright.spec.js index 4d89f84fbf..e8e2bb1587 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -59,6 +59,7 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { const GOLDEN_DIR = path.join(__dirname, 'golden-' + product.toLowerCase()); const OUTPUT_DIR = path.join(__dirname, 'output-' + product.toLowerCase()); + const ASSETS_DIR = path.join(__dirname, 'assets'); if (fs.existsSync(OUTPUT_DIR)) rm(OUTPUT_DIR); const {expect} = new Matchers({ @@ -76,6 +77,7 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { defaultBrowserOptions, playwrightPath, headless: !!defaultBrowserOptions.headless, + ASSETS_DIR, }; beforeAll(async() => { @@ -129,7 +131,7 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { // Page-level tests that are given a browser, a context and a page. // Each test is launched in a new browser context. - require('./accessibility.spec.js').addTests(testOptions); + require('../src/chromium/features/accessibility.spec.js').addTests(testOptions); require('./browser.spec.js').addTests(testOptions); require('./click.spec.js').addTests(testOptions); require('./cookies.spec.js').addTests(testOptions); @@ -155,9 +157,10 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { require('./worker.spec.js').addTests(testOptions); if (CHROME) { require('./CDPSession.spec.js').addTests(testOptions); - require('./coverage.spec.js').addTests(testOptions); + require('../src/chromium/features/coverage.spec.js').addTests(testOptions); // Add page-level Chromium-specific tests. require('./chromiumonly.spec.js').addPageTests(testOptions); + require('../src/chromium/features/pdf.spec.js').addTests(testOptions); } }); @@ -173,7 +176,7 @@ module.exports.addTests = ({testRunner, product, playwrightPath}) => { if (CHROME) { require('./oopif.spec.js').addTests(testOptions); require('./headful.spec.js').addTests(testOptions); - require('./tracing.spec.js').addTests(testOptions); + require('../src/chromium/features/tracing.spec.js').addTests(testOptions); // Add top-level Chromium-specific tests. require('./chromiumonly.spec.js').addLauncherTests(testOptions); }