From 17ebccbf288946acbe942bc3f16cc1bb7fe2ad71 Mon Sep 17 00:00:00 2001 From: Stacey Gammon Date: Fri, 22 Jun 2018 10:53:00 -0400 Subject: [PATCH] Typescript-ify screenshot stitcher code in reporting (#20061) (#20149) * typescript screenshot stitcher * Throw an error if the data captured is not of the expected width and height. * Import babel-core types * Add babel-core types to x-pack package.json * Dimensions => Rectangle --- package.json | 2 + x-pack/package.json | 3 +- .../server/browsers/chromium/driver/index.js | 3 +- .../driver/screenshot_stitcher/combine.js | 66 ---------- .../driver/screenshot_stitcher/combine.ts | 116 ++++++++++++++++++ .../driver/screenshot_stitcher/get_clips.js | 25 ---- .../screenshot_stitcher/get_clips.test.js | 101 --------------- .../screenshot_stitcher/get_clips.test.ts | 113 +++++++++++++++++ .../driver/screenshot_stitcher/get_clips.ts | 38 ++++++ .../driver/screenshot_stitcher/index.js | 55 --------- .../{index.test.js => index.test.ts} | 54 ++++++-- .../driver/screenshot_stitcher/index.ts | 95 ++++++++++++++ .../driver/screenshot_stitcher/types.ts | 28 +++++ x-pack/yarn.lock | 6 + yarn.lock | 43 +++++++ 15 files changed, 486 insertions(+), 262 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.js create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.js delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.js create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.ts create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.ts delete mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.js rename x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/{index.test.js => index.test.ts} (84%) create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.ts create mode 100644 x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/types.ts diff --git a/package.json b/package.json index 09c370036b73cb..624cf975b50cdd 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,8 @@ "@kbn/plugin-generator": "link:packages/kbn-plugin-generator", "@kbn/test": "link:packages/kbn-test", "@types/angular": "^1.6.45", + "@types/babel-core": "^6.25.5", + "@types/bluebird": "^3.1.1", "@types/classnames": "^2.2.3", "@types/eslint": "^4.16.2", "@types/execa": "^0.9.0", diff --git a/x-pack/package.json b/x-pack/package.json index 13de721704eb2a..3e7cec4785473a 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -28,6 +28,7 @@ "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/test": "link:../packages/kbn-test", "@types/jest": "^22.2.3", + "@types/pngjs": "^3.3.1", "abab": "^1.0.4", "ansicolors": "0.3.2", "aws-sdk": "2.2.33", @@ -82,6 +83,7 @@ "@elastic/numeral": "2.3.2", "@kbn/datemath": "link:../packages/kbn-datemath", "@kbn/ui-framework": "link:../packages/kbn-ui-framework", + "@samverschueren/stream-to-observable": "^0.3.0", "@slack/client": "^4.2.2", "angular-paging": "2.2.1", "angular-resource": "1.4.9", @@ -154,7 +156,6 @@ "rison-node": "0.3.1", "rxjs": "^6.1.0", "semver": "5.1.0", - "@samverschueren/stream-to-observable": "^0.3.0", "styled-components": "2.3.2", "tar-fs": "1.13.0", "tinycolor2": "1.3.0", diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/index.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/index.js index f7a2b3a7f0e25a..68863dd897e5e1 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/index.js +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/index.js @@ -101,7 +101,7 @@ export class HeadlessChromiumDriver { scale: 1 } }); - this._logger.debug(`captured screenshot clip ${JSON.stringify(screenshotClip)}`); + this._logger.debug(`Captured screenshot clip ${JSON.stringify(screenshotClip)}`); return data; }, this._logger); } @@ -112,6 +112,7 @@ export class HeadlessChromiumDriver { } async setViewport({ width, height, zoom }) { + this._logger.debug(`Setting viewport to width: ${width}, height: ${height}, zoom: ${zoom}`); const { Emulation } = this._client; await Emulation.setDeviceMetricsOverride({ diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.js deleted file mode 100644 index 953888453e75a8..00000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.js +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import $streamToObservable from '@samverschueren/stream-to-observable'; -import { PNG } from 'pngjs'; -import * as Rx from 'rxjs'; -import { mergeMap, reduce, tap, switchMap, toArray, map } from 'rxjs/operators'; - -// if we're only given one screenshot, and it matches the output dimensions -// we're going to skip the combination and just use it -const canUseFirstScreenshot = (screenshots, outputDimensions) => { - if (screenshots.length !== 1) { - return false; - } - - const firstScreenshot = screenshots[0]; - return firstScreenshot.dimensions.width === outputDimensions.width && - firstScreenshot.dimensions.height === outputDimensions.height; -}; - -export function $combine(screenshots, outputDimensions, logger) { - if (screenshots.length === 0) { - return Rx.throwError('Unable to combine 0 screenshots'); - } - - if (canUseFirstScreenshot(screenshots, outputDimensions)) { - return Rx.of(screenshots[0].data); - } - - const pngs$ = Rx.from(screenshots).pipe( - mergeMap( - ({ data }) => { - const png = new PNG(); - const buffer = Buffer.from(data, 'base64'); - const parseAsObservable = Rx.bindNodeCallback(png.parse.bind(png)); - return parseAsObservable(buffer); - }, - ({ dimensions }, png) => ({ dimensions, png }) - ) - ); - - const output$ = pngs$.pipe( - reduce( - (output, { dimensions, png }) => { - // Spitting out a lot of output to help debug https://github.com/elastic/kibana/issues/19563. Once that is - // fixed, this should probably get pared down. - logger.debug(`Output dimensions is ${JSON.stringify(outputDimensions)}`); - logger.debug(`Input png w: ${png.width} and h: ${png.height}`); - logger.debug(`Creating output png with ${JSON.stringify(dimensions)}`); - png.bitblt(output, 0, 0, dimensions.width, dimensions.height, dimensions.x, dimensions.y); - return output; - }, - new PNG({ width: outputDimensions.width, height: outputDimensions.height }) - ) - ); - - return output$.pipe( - tap(png => png.pack()), - switchMap($streamToObservable), - toArray(), - map(chunks => Buffer.concat(chunks).toString('base64')) - ); -} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.ts new file mode 100644 index 00000000000000..3e7454a3bd14aa --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/combine.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// No types found for this package. May want to investigate an alternative with types. +// @ts-ignore: implicit any for JS file +import $streamToObservable from '@samverschueren/stream-to-observable'; +import { PNG } from 'pngjs'; +import * as Rx from 'rxjs'; +import { ObservableInput } from 'rxjs'; +import { map, mergeMap, reduce, switchMap, tap, toArray } from 'rxjs/operators'; +import { Logger, Screenshot, Size } from './types'; + +// if we're only given one screenshot, and it matches the given size +// we're going to skip the combination and just use it +const canUseFirstScreenshot = ( + screenshots: Screenshot[], + size: { width: number; height: number } +) => { + if (screenshots.length !== 1) { + return false; + } + + const firstScreenshot = screenshots[0]; + return ( + firstScreenshot.rectangle.width === size.width && + firstScreenshot.rectangle.height === size.height + ); +}; + +/** + * Combines the screenshot clips into a single screenshot of size `outputSize`. + * @param screenshots - Array of screenshots to combine + * @param outputSize - Final output size that the screenshots should match up with + * @param logger - logger for extra debug output + */ +export function $combine( + screenshots: Screenshot[], + outputSize: Size, + logger: Logger +): Rx.Observable { + logger.debug( + `Combining screenshot clips into final, scaled output dimension of ${JSON.stringify( + outputSize + )}` + ); + + if (screenshots.length === 0) { + return Rx.throwError('Unable to combine 0 screenshots'); + } + + if (canUseFirstScreenshot(screenshots, outputSize)) { + return Rx.of(screenshots[0].data); + } + + // Turn the screenshot data into actual PNGs + const pngs$ = Rx.from(screenshots).pipe( + mergeMap( + (screenshot: Screenshot): ObservableInput => { + const png = new PNG(); + const buffer = Buffer.from(screenshot.data, 'base64'); + const parseAsObservable = Rx.bindNodeCallback(png.parse.bind(png)); + return parseAsObservable(buffer); + }, + (screenshot: Screenshot, png: PNG) => { + if ( + png.width !== screenshot.rectangle.width || + png.height !== screenshot.rectangle.height + ) { + const errorMessage = `Screenshot captured with width:${ + png.width + } and height: ${png.height}) is not of expected width: ${ + screenshot.rectangle.width + } and height: ${screenshot.rectangle.height}`; + + logger.error(errorMessage); + throw new Error(errorMessage); + } + return { screenshot, png }; + } + ) + ); + + const output$ = pngs$.pipe( + reduce((output: PNG, input: { screenshot: Screenshot; png: PNG }) => { + const { png, screenshot } = input; + // Spitting out a lot of output to help debug https://github.com/elastic/kibana/issues/19563. Once that is + // fixed, this should probably get pared down. + logger.debug(`Output dimensions is ${JSON.stringify(outputSize)}`); + logger.debug(`Input png w: ${png.width} and h: ${png.height}`); + logger.debug( + `Creating output png with ${JSON.stringify(screenshot.rectangle)}` + ); + const { rectangle } = screenshot; + png.bitblt( + output, + 0, + 0, + rectangle.width, + rectangle.height, + rectangle.x, + rectangle.y + ); + return output; + }, new PNG({ width: outputSize.width, height: outputSize.height })) + ); + + return output$.pipe( + tap(png => png.pack()), + switchMap($streamToObservable), + toArray(), + map((chunks: Buffer[]) => Buffer.concat(chunks).toString('base64')) + ); +} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.js deleted file mode 100644 index 38523c126ac164..00000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as Rx from 'rxjs'; - -export function $getClips(dimensions, max) { - return Rx.from(function* () { - const columns = Math.ceil(dimensions.width / max) || 1; - const rows = Math.ceil(dimensions.height / max) || 1; - - for (let row = 0; row < rows; ++row) { - for (let column = 0; column < columns; ++column) { - yield { - x: column * max + dimensions.x, - y: row * max + dimensions.y, - width: column === columns - 1 ? dimensions.width - (column * max) : max, - height: row === rows - 1 ? dimensions.height - (row * max) : max, - }; - } - } - }()); -} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.js deleted file mode 100644 index b30553155276cd..00000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.js +++ /dev/null @@ -1,101 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { toArray } from 'rxjs/operators'; -import { $getClips } from './get_clips'; - -function getClipsTest(description, { dimensions, max }, { clips: expectedClips }) { - test(description, async () => { - const clips = await $getClips(dimensions, max).pipe(toArray()).toPromise(); - expect(clips.length).toBe(expectedClips.length); - for (let i = 0; i < clips.length; ++i) { - expect(clips[i]).toEqual(expectedClips[i]); - } - }); -} - -getClipsTest(`creates one rect if 0, 0`, - { - dimensions: { x: 0, y: 0, height: 0, width: 0 }, - max: 100, - }, - { - clips: [{ x: 0, y: 0, height: 0, width: 0 }], - } -); - -getClipsTest(`creates one rect if smaller than max`, - { - dimensions: { x: 0, y: 0, height: 99, width: 99 }, - max: 100, - }, - { - clips: [{ x: 0, y: 0, height: 99, width: 99 }], - } -); - -getClipsTest(`create one rect if equal to max`, - { - dimensions: { x: 0, y: 0, height: 100, width: 100 }, - max: 100, - }, - { - clips: [{ x: 0, y: 0, height: 100, width: 100 }], - } -); - -getClipsTest(`creates two rects if width is 1.5 * max`, - { - dimensions: { x: 0, y: 0, height: 100, width: 150 }, - max: 100, - }, - { - clips: [ - { x: 0, y: 0, height: 100, width: 100 }, - { x: 100, y: 0, height: 100, width: 50 } - ], - } -); - -getClipsTest(`creates two rects if height is 1.5 * max`, - { - dimensions: { x: 0, y: 0, height: 150, width: 100 }, - max: 100, - }, - { - clips: [ - { x: 0, y: 0, height: 100, width: 100 }, - { x: 0, y: 100, height: 50, width: 100 } - ], - } -); - -getClipsTest(`created four rects if height and width is 1.5 * max`, - { - dimensions: { x: 0, y: 0, height: 150, width: 150 }, - max: 100, - }, - { - clips: [ - { x: 0, y: 0, height: 100, width: 100 }, - { x: 100, y: 0, height: 100, width: 50 }, - { x: 0, y: 100, height: 50, width: 100 }, - { x: 100, y: 100, height: 50, width: 50 }, - ], - } -); - -getClipsTest(`creates one rect if height and width is equal to max and theres a y equal to the max`, - { - dimensions: { x: 0, y: 100, height: 100, width: 100 }, - max: 100, - }, - { - clips: [ - { x: 0, y: 100, height: 100, width: 100 }, - ], - } -); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.ts new file mode 100644 index 00000000000000..aeccb99b5279bf --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.test.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toArray } from 'rxjs/operators'; +import { $getClips } from './get_clips'; +import { Rectangle } from './types'; + +function getClipsTest( + description: string, + input: { rectangle: Rectangle; max: number }, + expectedClips: { clips: Rectangle[] } +) { + test(description, async () => { + const clips = await $getClips(input.rectangle, input.max) + .pipe(toArray()) + .toPromise(); + expect(clips.length).toBe(expectedClips.clips.length); + for (let i = 0; i < clips.length; ++i) { + expect(clips[i]).toEqual(expectedClips.clips[i]); + } + }); +} + +getClipsTest( + `creates one rect if 0, 0`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 0, width: 0 }, + }, + { + clips: [{ x: 0, y: 0, height: 0, width: 0 }], + } +); + +getClipsTest( + `creates one rect if smaller than max`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 99, width: 99 }, + }, + { + clips: [{ x: 0, y: 0, height: 99, width: 99 }], + } +); + +getClipsTest( + `create one rect if equal to max`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 100, width: 100 }, + }, + { + clips: [{ x: 0, y: 0, height: 100, width: 100 }], + } +); + +getClipsTest( + `creates two rects if width is 1.5 * max`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 100, width: 150 }, + }, + { + clips: [ + { x: 0, y: 0, height: 100, width: 100 }, + { x: 100, y: 0, height: 100, width: 50 }, + ], + } +); + +getClipsTest( + `creates two rects if height is 1.5 * max`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 150, width: 100 }, + }, + { + clips: [ + { x: 0, y: 0, height: 100, width: 100 }, + { x: 0, y: 100, height: 50, width: 100 }, + ], + } +); + +getClipsTest( + `created four rects if height and width is 1.5 * max`, + { + max: 100, + rectangle: { x: 0, y: 0, height: 150, width: 150 }, + }, + { + clips: [ + { x: 0, y: 0, height: 100, width: 100 }, + { x: 100, y: 0, height: 100, width: 50 }, + { x: 0, y: 100, height: 50, width: 100 }, + { x: 100, y: 100, height: 50, width: 50 }, + ], + } +); + +getClipsTest( + `creates one rect if height and width is equal to max and theres a y equal to the max`, + { + max: 100, + rectangle: { x: 0, y: 100, height: 100, width: 100 }, + }, + { + clips: [{ x: 0, y: 100, height: 100, width: 100 }], + } +); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.ts new file mode 100644 index 00000000000000..090cd6cdb08806 --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/get_clips.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { Rectangle } from './types'; + +/** + * Takes one large rectangle and breaks it down into an array of smaller rectangles, + * that if stitched together would create the original rectangle. + * @param largeRectangle - A big rectangle that might be broken down into smaller rectangles + * @param max - Maximum width or height any single clip should have + */ +export function $getClips( + largeRectangle: Rectangle, + max: number +): Rx.Observable { + const rectanglesGenerator = function*(): IterableIterator { + const columns = Math.ceil(largeRectangle.width / max) || 1; + const rows = Math.ceil(largeRectangle.height / max) || 1; + + for (let row = 0; row < rows; ++row) { + for (let column = 0; column < columns; ++column) { + yield { + height: row === rows - 1 ? largeRectangle.height - row * max : max, + width: + column === columns - 1 ? largeRectangle.width - column * max : max, + x: column * max + largeRectangle.x, + y: row * max + largeRectangle.y, + }; + } + } + }; + + return Rx.from(rectanglesGenerator()); +} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.js deleted file mode 100644 index e9a6c1be7770a1..00000000000000 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { toArray, map, mergeMap, switchMap } from 'rxjs/operators'; -import { $getClips } from './get_clips'; -import { $combine } from './combine'; - -const scaleRect = (rect, scale) => { - return { - x: rect.x * scale, - y: rect.y * scale, - width: rect.width * scale, - height: rect.height * scale, - }; -}; - -export async function screenshotStitcher(outputClip, zoom, max, captureScreenshotFn, logger) { - // We have to divide the max by the zoom because we want to be limiting the resolution - // of the output screenshots, which is implicitly multiplied by the zoom, but we don't - // want the zoom to affect the clipping rects that we use - const screenshotClips$ = $getClips(outputClip, Math.ceil(max / zoom)); - - const screenshots$ = screenshotClips$.pipe( - mergeMap( - clip => captureScreenshotFn(clip), - (clip, data) => ({ clip, data }), - 1 - ) - ); - - // when we take the screenshots we don't have to scale the rects - // but the PNGs don't know about the zoom, so we have to scale them - const screenshotPngDimensions$ = screenshots$.pipe( - map( - ({ data, clip }) => ({ - data, - dimensions: scaleRect({ - x: clip.x - outputClip.x, - y: clip.y - outputClip.y, - width: clip.width, - height: clip.height, - }, zoom) - }) - ) - ); - - return screenshotPngDimensions$.pipe( - toArray(), - switchMap(screenshots => $combine(screenshots, scaleRect(outputClip, zoom), logger)), - ) - .toPromise(); -} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.js b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.ts similarity index 84% rename from x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.js rename to x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.ts index ab23c9e1bc8018..b519a0f6363a51 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.js +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.test.ts @@ -4,21 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ +import { promisify } from 'bluebird'; import fs from 'fs'; import path from 'path'; -import { promisify } from 'bluebird'; import { screenshotStitcher } from './index'; const loggerMock = { - debug: () => {} + debug: () => { + return; + }, + error: () => { + return; + }, + warning: () => { + return; + }, }; const fsp = { - readFile: promisify(fs.readFile) + readFile: promisify(fs.readFile), }; -const readPngFixture = async (filename) => { +const readPngFixture = async (filename: string) => { const buffer = await fsp.readFile(path.join(__dirname, 'fixtures', filename)); return buffer.toString('base64'); }; @@ -49,10 +57,10 @@ const get4x4Checkerboard = () => { test(`single screenshot`, async () => { const clip = { - x: 0, - y: 0, height: 1, width: 1, + x: 0, + y: 0, }; const fn = jest.fn(); @@ -68,10 +76,10 @@ test(`single screenshot`, async () => { test(`single screenshot, when zoom creates partial pixel we round up`, async () => { const clip = { - x: 0, - y: 0, height: 1, width: 1, + x: 0, + y: 0, }; const fn = jest.fn(); @@ -87,10 +95,31 @@ test(`single screenshot, when zoom creates partial pixel we round up`, async () test(`two screenshots, no zoom`, async () => { const clip = { + height: 1, + width: 2, x: 0, y: 0, + }; + + const fn = jest.fn(); + fn.mockReturnValueOnce(getSingleWhitePixel()); + fn.mockReturnValueOnce(getSingleBlackPixel()); + const data = await screenshotStitcher(clip, 1, 1, fn, loggerMock); + + expect(fn.mock.calls.length).toBe(2); + expect(fn.mock.calls[0][0]).toEqual({ x: 0, y: 0, width: 1, height: 1 }); + expect(fn.mock.calls[1][0]).toEqual({ x: 1, y: 0, width: 1, height: 1 }); + + const expectedData = await get2x1Checkerboard(); + expect(data).toEqual(expectedData); +}); + +test(`two screenshots, no zoom`, async () => { + const clip = { height: 1, width: 2, + x: 0, + y: 0, }; const fn = jest.fn(); @@ -108,10 +137,10 @@ test(`two screenshots, no zoom`, async () => { test(`four screenshots, zoom`, async () => { const clip = { - x: 0, - y: 0, height: 2, width: 2, + x: 0, + y: 0, }; const fn = jest.fn(); @@ -132,13 +161,12 @@ test(`four screenshots, zoom`, async () => { expect(data).toEqual(expectedData); }); - test(`four screenshots, zoom and offset`, async () => { const clip = { - x: 1, - y: 1, height: 2, width: 2, + x: 1, + y: 1, }; const fn = jest.fn(); diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.ts new file mode 100644 index 00000000000000..dae6af27c1f0ce --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/index.ts @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { map, mergeMap, switchMap, toArray } from 'rxjs/operators'; +import { $combine } from './combine'; +import { $getClips } from './get_clips'; +import { Logger, Rectangle, Screenshot } from './types'; + +const scaleRect = (rect: Rectangle, scale: number): Rectangle => { + return { + height: rect.height * scale, + width: rect.width * scale, + x: rect.x * scale, + y: rect.y * scale, + }; +}; + +/** + * Returns a stream of data that should be of the size outputClip.width * zoom x outputClip.height * zoom + * @param outputClip - The dimensions the final image should take. + * @param zoom - Determines the resolution want the final screenshot to take. + * @param maxDimensionPerClip - The maximimum dimension, in any direction (width or height) that we should allow per + * screenshot clip. If zoom is 10 and maxDimensionPerClip is anything less than or + * equal to 10, each clip taken, before being zoomed in, should be no bigger than 1 x 1. + * If zoom is 10 and maxDimensionPerClip is 20, then each clip taken before being zoomed in should be no bigger than 2 x 2. + * @param captureScreenshotFn - a function to take a screenshot from the page using the dimensions given. The data + * returned should have dimensions not of the clip passed in, but of the clip passed in multiplied by zoom. + * @param logger + */ +export async function screenshotStitcher( + outputClip: Rectangle, + zoom: number, + maxDimensionPerClip: number, + captureScreenshotFn: (rect: Rectangle) => Promise, + logger: Logger +): Promise { + // We have to divide the max by the zoom because we will be multiplying each clip's dimensions + // later by zoom, and we don't want those dimensions to end up larger than max. + const maxDimensionBeforeZoom = Math.ceil(maxDimensionPerClip / zoom); + const screenshotClips$ = $getClips(outputClip, maxDimensionBeforeZoom); + + const screenshots$ = screenshotClips$.pipe( + mergeMap( + clip => captureScreenshotFn(clip), + (clip, data) => ({ clip, data }), + 1 + ) + ); + + // when we take the screenshots we don't have to scale the rects + // but the PNGs don't know about the zoom, so we have to scale them + const screenshotPngRects$ = screenshots$.pipe( + map(({ data, clip }) => { + // At this point we don't care about the offset - the screenshots have been taken. + // We need to adjust the x & y values so they all are adjusted for the top-left most + // clip being at 0, 0. + const x = clip.x - outputClip.x; + const y = clip.y - outputClip.y; + + const scaledScreenshotRects = scaleRect( + { + height: clip.height, + width: clip.width, + x, + y, + }, + zoom + ); + return { + data, + rectangle: scaledScreenshotRects, + }; + }) + ); + + const scaledOutputRects = scaleRect(outputClip, zoom); + return screenshotPngRects$ + .pipe( + toArray(), + switchMap((screenshots: Screenshot[]) => + $combine( + screenshots, + { + height: scaledOutputRects.height, + width: scaledOutputRects.width, + }, + logger + ) + ) + ) + .toPromise(); +} diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/types.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/types.ts new file mode 100644 index 00000000000000..d99a61a7a7c63f --- /dev/null +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver/screenshot_stitcher/types.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Rectangle { + width: number; + height: number; + x: number; + y: number; +} + +export interface Size { + width: number; + height: number; +} + +export interface Screenshot { + data: string; + rectangle: Rectangle; +} + +export interface Logger { + debug: (message: string) => void; + error: (message: string) => void; + warning: (message: string) => void; +} diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index 3d84872db8c6bf..7401e758654fc2 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -174,6 +174,12 @@ dependencies: "@types/retry" "*" +"@types/pngjs@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@types/pngjs/-/pngjs-3.3.1.tgz#47d97bd29dd6372856050e9e5e366517dd1ba2d8" + dependencies: + "@types/node" "*" + "@types/retry@*", "@types/retry@^0.10.2": version "0.10.2" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" diff --git a/yarn.lock b/yarn.lock index a9f2e2a56cdd3e..e78912e6ec06f4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -224,6 +224,49 @@ version "1.6.45" resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.6.45.tgz#5b0b91a51d717f6fc816d59e1234d5292f33f7b9" +"@types/babel-core@^6.25.5": + version "6.25.5" + resolved "https://registry.yarnpkg.com/@types/babel-core/-/babel-core-6.25.5.tgz#7598b1287c2cb5a8e9150d60e4d4a8f2dbe29982" + dependencies: + "@types/babel-generator" "*" + "@types/babel-template" "*" + "@types/babel-traverse" "*" + "@types/babel-types" "*" + "@types/babylon" "*" + +"@types/babel-generator@*": + version "6.25.2" + resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.2.tgz#fa13653ec2d34a4037be9c34dec32ae75bea04cc" + dependencies: + "@types/babel-types" "*" + +"@types/babel-template@*": + version "6.25.1" + resolved "https://registry.yarnpkg.com/@types/babel-template/-/babel-template-6.25.1.tgz#03e23a893c16bab2ec00200ab51feccf488cae78" + dependencies: + "@types/babel-types" "*" + "@types/babylon" "*" + +"@types/babel-traverse@*": + version "6.25.4" + resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.4.tgz#269af6a25c80419b635c8fa29ae42b0d5ce2418c" + dependencies: + "@types/babel-types" "*" + +"@types/babel-types@*": + version "7.0.4" + resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.4.tgz#bfd5b0d0d1ba13e351dff65b6e52783b816826c8" + +"@types/babylon@*": + version "6.16.3" + resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.3.tgz#c2937813a89fcb5e79a00062fc4a8b143e7237bb" + dependencies: + "@types/babel-types" "*" + +"@types/bluebird@^3.1.1": + version "3.5.20" + resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.20.tgz#f6363172add6f4eabb8cada53ca9af2781e8d6a1" + "@types/classnames@^2.2.3": version "2.2.3" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"