mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
Typescript-ify screenshot stitcher code in reporting (#20061)
* 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
This commit is contained in:
parent
36d29e4fcc
commit
2c1fcf9604
15 changed files with 488 additions and 264 deletions
|
@ -227,6 +227,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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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'))
|
||||
);
|
||||
}
|
|
@ -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<string> {
|
||||
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<PNG> => {
|
||||
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<PNG, Buffer>($streamToObservable),
|
||||
toArray(),
|
||||
map((chunks: Buffer[]) => Buffer.concat(chunks).toString('base64'))
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
|
@ -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 },
|
||||
],
|
||||
}
|
||||
);
|
|
@ -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 }],
|
||||
}
|
||||
);
|
|
@ -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<Rectangle> {
|
||||
const rectanglesGenerator = function*(): IterableIterator<Rectangle> {
|
||||
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());
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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 = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
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();
|
|
@ -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<string>,
|
||||
logger: Logger
|
||||
): Promise<string> {
|
||||
// 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<Screenshot[], string>((screenshots: Screenshot[]) =>
|
||||
$combine(
|
||||
screenshots,
|
||||
{
|
||||
height: scaledOutputRects.height,
|
||||
width: scaledOutputRects.width,
|
||||
},
|
||||
logger
|
||||
)
|
||||
)
|
||||
)
|
||||
.toPromise<string>();
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
|
|
43
yarn.lock
43
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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue