feat(screenshot): size:'css'|'device' option (#12634)

With this experimental option, screenshot dimensions are in CSS pixels,
not physical device pixels, effectively ignoring the device scale factor.
This commit is contained in:
Dmitry Gozman 2022-03-10 13:07:10 -08:00 committed by GitHub
parent 372a3219f3
commit a388bb2302
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 133 additions and 9 deletions

View file

@ -952,11 +952,17 @@ When true, takes a screenshot of the full scrollable page, instead of the curren
An object which specifies clipping of the resulting image. Should have the following fields: An object which specifies clipping of the resulting image. Should have the following fields:
## screenshot-option-size
- `size` <[ScreenshotSize]<"css"|"device">>
When set to `css`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this will keep screenshots small. Using `device` option will produce a single pixel per each device pixel, so screenhots of high-dpi devices will be twice as large or even larger. Defaults to `device`.
## screenshot-options-common-list ## screenshot-options-common-list
- %%-screenshot-option-animations-%% - %%-screenshot-option-animations-%%
- %%-screenshot-option-omit-background-%% - %%-screenshot-option-omit-background-%%
- %%-screenshot-option-quality-%% - %%-screenshot-option-quality-%%
- %%-screenshot-option-path-%% - %%-screenshot-option-path-%%
- %%-screenshot-option-size-%%
- %%-screenshot-option-type-%% - %%-screenshot-option-type-%%
- %%-screenshot-option-mask-%% - %%-screenshot-option-mask-%%
- %%-input-timeout-%% - %%-input-timeout-%%

View file

@ -1549,6 +1549,7 @@ export type PageScreenshotParams = {
fullPage?: boolean, fullPage?: boolean,
animations?: 'disabled', animations?: 'disabled',
clip?: Rect, clip?: Rect,
size?: 'css' | 'device',
mask?: { mask?: {
frame: FrameChannel, frame: FrameChannel,
selector: string, selector: string,
@ -1562,6 +1563,7 @@ export type PageScreenshotOptions = {
fullPage?: boolean, fullPage?: boolean,
animations?: 'disabled', animations?: 'disabled',
clip?: Rect, clip?: Rect,
size?: 'css' | 'device',
mask?: { mask?: {
frame: FrameChannel, frame: FrameChannel,
selector: string, selector: string,
@ -2861,6 +2863,7 @@ export type ElementHandleScreenshotParams = {
quality?: number, quality?: number,
omitBackground?: boolean, omitBackground?: boolean,
animations?: 'disabled', animations?: 'disabled',
size?: 'css' | 'device',
mask?: { mask?: {
frame: FrameChannel, frame: FrameChannel,
selector: string, selector: string,
@ -2872,6 +2875,7 @@ export type ElementHandleScreenshotOptions = {
quality?: number, quality?: number,
omitBackground?: boolean, omitBackground?: boolean,
animations?: 'disabled', animations?: 'disabled',
size?: 'css' | 'device',
mask?: { mask?: {
frame: FrameChannel, frame: FrameChannel,
selector: string, selector: string,

View file

@ -1050,6 +1050,11 @@ Page:
literals: literals:
- disabled - disabled
clip: Rect? clip: Rect?
size:
type: enum?
literals:
- css
- device
mask: mask:
type: array? type: array?
items: items:
@ -2214,6 +2219,11 @@ ElementHandle:
type: enum? type: enum?
literals: literals:
- disabled - disabled
size:
type: enum?
literals:
- css
- device
mask: mask:
type: array? type: array?
items: items:

View file

@ -572,6 +572,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
fullPage: tOptional(tBoolean), fullPage: tOptional(tBoolean),
animations: tOptional(tEnum(['disabled'])), animations: tOptional(tEnum(['disabled'])),
clip: tOptional(tType('Rect')), clip: tOptional(tType('Rect')),
size: tOptional(tEnum(['css', 'device'])),
mask: tOptional(tArray(tObject({ mask: tOptional(tArray(tObject({
frame: tChannel('Frame'), frame: tChannel('Frame'),
selector: tString, selector: tString,
@ -1066,6 +1067,7 @@ export function createScheme(tChannel: (name: string) => Validator): Scheme {
quality: tOptional(tNumber), quality: tOptional(tNumber),
omitBackground: tOptional(tBoolean), omitBackground: tOptional(tBoolean),
animations: tOptional(tEnum(['disabled'])), animations: tOptional(tEnum(['disabled'])),
size: tOptional(tEnum(['css', 'device'])),
mask: tOptional(tArray(tObject({ mask: tOptional(tArray(tObject({
frame: tChannel('Frame'), frame: tChannel('Frame'),
selector: tString, selector: tString,

View file

@ -248,7 +248,7 @@ export class CRPage implements PageDelegate {
await this._mainFrameSession._client.send('Emulation.setDefaultBackgroundColorOverride', { color }); await this._mainFrameSession._client.send('Emulation.setDefaultBackgroundColorOverride', { color });
} }
async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean | undefined): Promise<Buffer> { async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, size: 'css' | 'device'): Promise<Buffer> {
const { visualViewport } = await this._mainFrameSession._client.send('Page.getLayoutMetrics'); const { visualViewport } = await this._mainFrameSession._client.send('Page.getLayoutMetrics');
if (!documentRect) { if (!documentRect) {
documentRect = { documentRect = {
@ -263,6 +263,10 @@ export class CRPage implements PageDelegate {
// When taking screenshots with documentRect (based on the page content, not viewport), // When taking screenshots with documentRect (based on the page content, not viewport),
// ignore current page scale. // ignore current page scale.
const clip = { ...documentRect, scale: viewportRect ? visualViewport.scale : 1 }; const clip = { ...documentRect, scale: viewportRect ? visualViewport.scale : 1 };
if (size === 'css') {
const deviceScaleFactor = this._browserContext._options.deviceScaleFactor || 1;
clip.scale /= deviceScaleFactor;
}
progress.throwIfAborted(); progress.throwIfAborted();
const result = await this._mainFrameSession._client.send('Page.captureScreenshot', { format, quality, clip, captureBeyondViewport: !fitsViewport }); const result = await this._mainFrameSession._client.send('Page.captureScreenshot', { format, quality, clip, captureBeyondViewport: !fitsViewport });
return Buffer.from(result.data, 'base64'); return Buffer.from(result.data, 'base64');

View file

@ -408,7 +408,7 @@ export class FFPage implements PageDelegate {
throw new Error('Not implemented'); throw new Error('Not implemented');
} }
async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined): Promise<Buffer> { async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, size: 'css' | 'device'): Promise<Buffer> {
if (!documentRect) { if (!documentRect) {
const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY })); const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY }));
documentRect = { documentRect = {
@ -424,6 +424,7 @@ export class FFPage implements PageDelegate {
const { data } = await this._session.send('Page.screenshot', { const { data } = await this._session.send('Page.screenshot', {
mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'),
clip: documentRect, clip: documentRect,
omitDeviceScaleFactor: size === 'css',
}); });
return Buffer.from(data, 'base64'); return Buffer.from(data, 'base64');
} }

View file

@ -61,7 +61,7 @@ export interface PageDelegate {
bringToFront(): Promise<void>; bringToFront(): Promise<void>;
setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void>; setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void>;
takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean | undefined): Promise<Buffer>; takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, size: 'css' | 'device'): Promise<Buffer>;
isElementHandle(remoteObject: any): boolean; isElementHandle(remoteObject: any): boolean;
adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>>; adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.FrameExecutionContext): Promise<dom.ElementHandle<T>>;

View file

@ -40,6 +40,7 @@ export type ScreenshotOptions = {
mask?: { frame: Frame, selector: string}[], mask?: { frame: Frame, selector: string}[],
fullPage?: boolean, fullPage?: boolean,
clip?: Rect, clip?: Rect,
size?: 'css' | 'device',
}; };
export class Screenshotter { export class Screenshotter {
@ -237,7 +238,7 @@ export class Screenshotter {
progress.cleanupWhenAborted(() => this._page.hideHighlight()); progress.cleanupWhenAborted(() => this._page.hideHighlight());
} }
private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean | undefined, options: ScreenshotOptions): Promise<Buffer> { private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise<Buffer> {
if ((options as any).__testHookBeforeScreenshot) if ((options as any).__testHookBeforeScreenshot)
await (options as any).__testHookBeforeScreenshot(); await (options as any).__testHookBeforeScreenshot();
progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work. progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work.
@ -251,7 +252,7 @@ export class Screenshotter {
await this._maskElements(progress, options); await this._maskElements(progress, options);
progress.throwIfAborted(); // Avoid extra work. progress.throwIfAborted(); // Avoid extra work.
const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, options.quality, fitsViewport); const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, options.quality, fitsViewport, options.size || 'device');
progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
await this._page.hideHighlight(); await this._page.hideHighlight();

View file

@ -811,9 +811,9 @@ export class WKPage implements PageDelegate {
this._recordingVideoFile = null; this._recordingVideoFile = null;
} }
async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined): Promise<Buffer> { async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, size: 'css' | 'device'): Promise<Buffer> {
const rect = (documentRect || viewportRect)!; const rect = (documentRect || viewportRect)!;
const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport' }); const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor: size === 'css' });
const prefix = 'data:image/png;base64,'; const prefix = 'data:image/png;base64,';
let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (format === 'jpeg') if (format === 'jpeg')

View file

@ -8097,6 +8097,13 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
*/ */
quality?: number; quality?: number;
/**
* When set to `css`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this will
* keep screenshots small. Using `device` option will produce a single pixel per each device pixel, so screenhots of
* high-dpi devices will be twice as large or even larger. Defaults to `device`.
*/
size?: "css"|"device";
/** /**
* Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by * Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by
* using the * using the
@ -15611,6 +15618,13 @@ export interface LocatorScreenshotOptions {
*/ */
quality?: number; quality?: number;
/**
* When set to `css`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this will
* keep screenshots small. Using `device` option will produce a single pixel per each device pixel, so screenhots of
* high-dpi devices will be twice as large or even larger. Defaults to `device`.
*/
size?: "css"|"device";
/** /**
* Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by * Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by
* using the * using the
@ -15779,6 +15793,13 @@ export interface PageScreenshotOptions {
*/ */
quality?: number; quality?: number;
/**
* When set to `css`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this will
* keep screenshots small. Using `device` option will produce a single pixel per each device pixel, so screenhots of
* high-dpi devices will be twice as large or even larger. Defaults to `device`.
*/
size?: "css"|"device";
/** /**
* Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by * Maximum time in milliseconds, defaults to 30 seconds, pass `0` to disable timeout. The default value can be changed by
* using the * using the

View file

@ -41,7 +41,6 @@ browserTest.describe('page screenshot', () => {
browserTest('should work with a mobile viewport', async ({ browser, server, browserName }) => { browserTest('should work with a mobile viewport', async ({ browser, server, browserName }) => {
browserTest.skip(browserName === 'firefox'); browserTest.skip(browserName === 'firefox');
browserTest.fixme(browserName === 'chromium');
const context = await browser.newContext({ viewport: { width: 320, height: 480 }, isMobile: true }); const context = await browser.newContext({ viewport: { width: 320, height: 480 }, isMobile: true });
const page = await context.newPage(); const page = await context.newPage();
@ -53,7 +52,6 @@ browserTest.describe('page screenshot', () => {
browserTest('should work with a mobile viewport and clip', async ({ browser, server, browserName, channel }) => { browserTest('should work with a mobile viewport and clip', async ({ browser, server, browserName, channel }) => {
browserTest.skip(browserName === 'firefox'); browserTest.skip(browserName === 'firefox');
browserTest.skip(!!channel, 'Different result in stable/beta');
const context = await browser.newContext({ viewport: { width: 320, height: 480 }, isMobile: true }); const context = await browser.newContext({ viewport: { width: 320, height: 480 }, isMobile: true });
const page = await context.newPage(); const page = await context.newPage();
@ -83,6 +81,33 @@ browserTest.describe('page screenshot', () => {
await context.close(); await context.close();
}); });
browserTest('should work with device scale factor and clip', async ({ browser, server }) => {
const context = await browser.newContext({ viewport: { width: 500, height: 500 }, deviceScaleFactor: 3 });
const page = await context.newPage();
await page.goto(server.PREFIX + '/grid.html');
const screenshot = await page.screenshot({ clip: { x: 50, y: 100, width: 150, height: 100 } });
expect(screenshot).toMatchSnapshot('screenshot-device-scale-factor-clip.png');
await context.close();
});
browserTest('should work with device scale factor and size:css', async ({ browser, server }) => {
const context = await browser.newContext({ viewport: { width: 320, height: 480 }, deviceScaleFactor: 2 });
const page = await context.newPage();
await page.goto(server.PREFIX + '/grid.html');
const screenshot = await page.screenshot({ size: 'css' });
expect(screenshot).toMatchSnapshot('screenshot-device-scale-factor-css-size.png');
await context.close();
});
browserTest('should work with device scale factor, clip and size:css', async ({ browser, server }) => {
const context = await browser.newContext({ viewport: { width: 500, height: 500 }, deviceScaleFactor: 3 });
const page = await context.newPage();
await page.goto(server.PREFIX + '/grid.html');
const screenshot = await page.screenshot({ clip: { x: 50, y: 100, width: 150, height: 100 }, size: 'css' });
expect(screenshot).toMatchSnapshot('screenshot-device-scale-factor-clip-css-size.png');
await context.close();
});
browserTest('should work with large size', async ({ browserName, headless, platform, contextFactory }) => { browserTest('should work with large size', async ({ browserName, headless, platform, contextFactory }) => {
browserTest.fixme(browserName === 'chromium' && !headless && platform === 'linux', 'Chromium has gpu problems on linux with large screnshots'); browserTest.fixme(browserName === 'chromium' && !headless && platform === 'linux', 'Chromium has gpu problems on linux with large screnshots');
browserTest.slow(true, 'Large screenshot is slow'); browserTest.slow(true, 'Large screenshot is slow');
@ -345,4 +370,54 @@ browserTest.describe('element screenshot', () => {
await page.close(); await page.close();
} }
}); });
browserTest('should capture full element when larger than viewport with device scale factor', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 501, height: 501 }, deviceScaleFactor: 2.5 });
const page = await context.newPage();
await page.setContent(`
<div style="height: 14px">oooo</div>
<style>
div.to-screenshot {
border: 4px solid red;
box-sizing: border-box;
width: 600px;
height: 600px;
margin-left: 50px;
background: rgb(0, 100, 200);
}
::-webkit-scrollbar{
display: none;
}
</style>
<div class="to-screenshot"></div>
`);
const screenshot = await page.locator('div.to-screenshot').screenshot();
expect(screenshot).toMatchSnapshot('element-larger-than-viewport-dsf.png');
await context.close();
});
browserTest('should capture full element when larger than viewport with device scale factor and size:css', async ({ browser }) => {
const context = await browser.newContext({ viewport: { width: 501, height: 501 }, deviceScaleFactor: 2.5 });
const page = await context.newPage();
await page.setContent(`
<div style="height: 14px">oooo</div>
<style>
div.to-screenshot {
border: 4px solid red;
box-sizing: border-box;
width: 600px;
height: 600px;
margin-left: 50px;
background: rgb(0, 100, 200);
}
::-webkit-scrollbar{
display: none;
}
</style>
<div class="to-screenshot"></div>
`);
const screenshot = await page.locator('div.to-screenshot').screenshot({ size: 'css' });
expect(screenshot).toMatchSnapshot('element-larger-than-viewport-dsf-css-size.png');
await context.close();
});
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB