diff --git a/.buildkite/scripts/steps/security/third_party_packages.txt b/.buildkite/scripts/steps/security/third_party_packages.txt index 9cd5a325399c5..d47b2643780e6 100644 --- a/.buildkite/scripts/steps/security/third_party_packages.txt +++ b/.buildkite/scripts/steps/security/third_party_packages.txt @@ -25,6 +25,7 @@ ink @hey-api/openapi-ts vscode-languageserver-textdocument yaml-language-server +@pdf-lib/upng @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitbox @opentelemetry/exporter-metrics-otlp-proto diff --git a/package.json b/package.json index afa1bece016af..3c9cf009918e5 100644 --- a/package.json +++ b/package.json @@ -1227,6 +1227,7 @@ "@opentelemetry/instrumentation-undici": "0.19.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/semantic-conventions": "1.38.0", + "@pdf-lib/upng": "1.0.1", "@reduxjs/toolkit": "1.9.7", "@slack/webhook": "7.0.6", "@smithy/eventstream-codec": "4.1.1", diff --git a/renovate.json b/renovate.json index d8e30bb3ae7e8..43e6652e042d6 100644 --- a/renovate.json +++ b/renovate.json @@ -2907,6 +2907,7 @@ "@types/extract-zip", "@types/pdfmake", "extract-zip", + "@pdf-lib/upng", "pdfmake" ], "reviewers": [ diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/validator.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/validator.ts index e999977dc6bea..503a69731581f 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/validator.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/validator.ts @@ -41,7 +41,7 @@ const timezoneSchema = z.string().refine((val) => validateTimezone(val) === unde const dimensionsSchema = z .object({ - height: z.number().positive().max(14400), + height: z.number().positive().max(32000), width: z.number().positive().max(14400), }) .strict(); diff --git a/x-pack/platform/plugins/shared/screenshotting/server/__mocks__/puppeteer.ts b/x-pack/platform/plugins/shared/screenshotting/server/__mocks__/puppeteer.ts index f295192723c2b..a0756df86dacc 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/__mocks__/puppeteer.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/__mocks__/puppeteer.ts @@ -22,7 +22,7 @@ const stubPage = { isClosed: jest.fn(), setViewport: jest.fn(), evaluate: jest.fn(), - screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`), + screenshot: jest.fn().mockResolvedValue(new Uint8Array([3, 1, 4, 1, 5])), evaluateOnNewDocument: jest.fn(), setRequestInterception: jest.fn(), _client: jest.fn(() => ({ on: jest.fn() })), diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.test.ts index 14b26234796c5..3029c5e8dd962 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.test.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.test.ts @@ -13,6 +13,9 @@ import type { Size } from '../../../common/layout'; import { PreserveLayout } from '../../layouts/preserve_layout'; import { HeadlessChromiumDriver } from './driver'; +// from __mocks__/puppeteer.ts +const SCREENSHOT_BYTES = new Uint8Array([3, 1, 4, 1, 5]); + describe('chromium driver', () => { let mockConfig: ConfigType; let mockLogger: Logger; @@ -47,7 +50,7 @@ describe('chromium driver', () => { }; mockPage = { - screenshot: jest.fn().mockResolvedValue(`you won't believe this one weird screenshot`), + screenshot: jest.fn().mockResolvedValue(SCREENSHOT_BYTES), evaluate: jest.fn(), } as unknown as puppeteer.Page; @@ -71,6 +74,7 @@ describe('chromium driver', () => { ); const result = await driver.screenshot({ + logger: mockLogger, elementPosition: { boundingClientRect: { top: 200, left: 10, height: 10, width: 100 }, scroll: { x: 100, y: 300 }, @@ -78,7 +82,7 @@ describe('chromium driver', () => { layout: new PreserveLayout({ width: 16, height: 16 }), }); - expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64')); + expect(result).toEqual(Buffer.from(SCREENSHOT_BYTES)); }); it('add error to screenshot contents', async () => { @@ -93,6 +97,7 @@ describe('chromium driver', () => { const testSpy = jest.spyOn(driver, 'injectScreenshottingErrorHeader'); const result = await driver.screenshot({ + logger: mockLogger, elementPosition: { boundingClientRect: { top: 200, left: 10, height: 10, width: 100 }, scroll: { x: 100, y: 300 }, @@ -107,7 +112,7 @@ describe('chromium driver', () => { "[data-shared-items-container]", ] `); - expect(result).toEqual(Buffer.from(`you won't believe this one weird screenshot`, 'base64')); + expect(result).toEqual(Buffer.from(SCREENSHOT_BYTES)); }); it('sets the PDF image size', async () => { @@ -123,6 +128,7 @@ describe('chromium driver', () => { const testSpy = jest.spyOn(layout, 'setPdfImageSize'); await driver.screenshot({ + logger: mockLogger, elementPosition: { boundingClientRect: { top: 200, left: 10, height: 10, width: 100 }, scroll: { x: 100, y: 300 }, diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts index 6dd28499d39d3..f21fa03ce8cc1 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/driver.ts @@ -20,7 +20,7 @@ import { getPrintLayoutSelectors } from '../../layouts/print_layout'; import { allowRequest } from '../network_policy'; import { stripUnsafeHeaders } from './strip_unsafe_headers'; import { getFooterTemplate, getHeaderTemplate } from './templates'; - +import { getTiledScreenshot } from './tiled_screenshot'; declare module 'puppeteer' { interface Page { _client(): CDPSession; @@ -257,10 +257,12 @@ export class HeadlessChromiumDriver { * Receive a PNG buffer of the page screenshot from Chromium */ public async screenshot({ + logger, elementPosition, layout, error, }: { + logger: Logger; elementPosition: ElementPosition; layout: Layout; error?: Error; @@ -276,25 +278,17 @@ export class HeadlessChromiumDriver { width: boundingClientRect.width, }); - const screenshot = await this.page.screenshot({ - clip: { + return await getTiledScreenshot({ + logger, + page: this.page, + rect: { x: boundingClientRect.left + scroll.x, y: boundingClientRect.top + scroll.y, height: boundingClientRect.height, width: boundingClientRect.width, }, - captureBeyondViewport: false, // workaround for an internal resize. See: https://github.com/puppeteer/puppeteer/issues/7043 + zoom: layout.getBrowserZoom(), }); - - if (screenshot.byteLength) { - return Buffer.from(screenshot); - } - - if (typeof screenshot === 'string') { - return Buffer.from(screenshot, 'base64'); - } - - return undefined; } evaluate( diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.test.ts new file mode 100644 index 0000000000000..b4e4e0d6a222f --- /dev/null +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { partitionScreen, ROWS_PER_TILE } from './tiled_screenshot'; + +describe('stitched_screenshot', () => { + describe('partitionScreen', () => { + it('handles 0 size image', () => { + const result = partitionScreen({ x: 0, y: 0, width: 0, height: 0 }); + expect(result).toMatchInlineSnapshot(`Array []`); + }); + + it('handles a small image', () => { + const result = partitionScreen({ x: 1, y: 2, width: 10, height: 20 }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "height": 20, + "width": 10, + "x": 1, + "y": 2, + }, + ] + `); + }); + + const ROW_MULTIPLE = ROWS_PER_TILE * 2; + + it('handles a large image, off multiple of tile size by -1', () => { + const result = partitionScreen({ x: 3, y: 4, width: 2000, height: ROW_MULTIPLE - 1 }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "height": 1999, + "width": 2000, + "x": 3, + "y": 4, + }, + ] + `); + }); + + it('handles a large image, multiple of tile size', () => { + const result = partitionScreen({ x: 3, y: 4, width: 2000, height: ROW_MULTIPLE }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "height": 2000, + "width": 2000, + "x": 3, + "y": 4, + }, + ] + `); + }); + + it('handles a large image, off multiple of tile size by +1', () => { + const result = partitionScreen({ x: 3, y: 4, width: 2000, height: ROW_MULTIPLE + 1 }); + expect(result).toMatchInlineSnapshot(` + Array [ + Object { + "height": 2001, + "width": 2000, + "x": 3, + "y": 4, + }, + ] + `); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.ts b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.ts new file mode 100644 index 0000000000000..4ceb7d3a72224 --- /dev/null +++ b/x-pack/platform/plugins/shared/screenshotting/server/browsers/chromium/tiled_screenshot.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import type { Page, BoundingBox } from 'puppeteer'; +import UPNG from '@pdf-lib/upng'; + +export const ROWS_PER_TILE = 1000; +export const TILED_ROW_HEIGHT = 8000; + +interface GetScreenshotParams { + logger: Logger; + page: Page; + rect: BoundingBox; + zoom: number; +} + +export async function getTiledScreenshot(params: GetScreenshotParams): Promise { + try { + return getTiledScreenshotWrapped(params); + } catch (err) { + params.logger.error(`error generating screenshot: ${err.message}`, err); + return; + } +} + +export async function getTiledScreenshotWrapped( + params: GetScreenshotParams +): Promise { + const { page, rect, zoom, logger } = params; + + const tiles = partitionScreen(rect); + if (tiles.length === 0) { + logger.warn('screenshot was 0-sized, skipping'); + return; + } + + // disable tiling for now + if (tiles.length > 0) { + const screenshot = await getSingleScreenshot(params); + return Buffer.from(screenshot); + } + + const messagePrefix = 'getTiledScreenshot: '; + const messageMeta = { tags: ['tiled-report'] }; + const debug = (message: string) => logger.debug(`${messagePrefix}${message}`, messageMeta); + + // 4 bytes for each pixel (RGBA), multiply by zoom (both dimensions) + const bufferSize = 4 * zoom * zoom * rect.width * rect.height; + const result = new Uint8Array(new ArrayBuffer(bufferSize)); + debug( + `allocated buffer: width: ${rect.width}; height: ${rect.height}; bufferSize: ${bufferSize}` + ); + + let offset = 0; + for (const tile of tiles) { + debug(`getting tile: ${JSON.stringify(tile)}`); + const screenshot = await getSingleScreenshot({ logger, page, rect: tile, zoom }); + const image = UPNG.decode(screenshot); + const rgbaBytes = UPNG.toRGBA8(image)[0]; + debug(`got tile: width: ${image.width}; height: ${image.height}; depth: ${image.depth}`); + const imageData = new Uint8Array(rgbaBytes); + + debug(`copying tile to buffer offset: ${offset}`); + result.set(imageData, offset); + + offset += rgbaBytes.byteLength; + } + + const finalWidth = rect.width * zoom; + const finalHeight = rect.height * zoom; + debug(`result image: width: ${finalWidth}; height: ${finalHeight}`); + + const resultImage = UPNG.encode([result.buffer], finalWidth, finalHeight, 0); + return Buffer.from(resultImage); +} + +async function getSingleScreenshot(params: GetScreenshotParams): Promise { + const { page } = params; + + const image = await page.screenshot({ + clip: params.rect, + captureBeyondViewport: false, // workaround for an internal resize. See: https://github.com/puppeteer/puppeteer/issues/7043 + }); + return image.buffer as ArrayBuffer; +} + +// Split a page into tiles but only length-wise, as it's easy to +// concatenate rows, much harder to concatenate columns. And we've +// only seen issues with "big dashboards" be long ones, not wide ones. +export function partitionScreen(rect: BoundingBox): BoundingBox[] { + const result: BoundingBox[] = []; + + const { x, y, width, height } = rect; + if (height <= 0) return []; + + if (height <= TILED_ROW_HEIGHT) { + return [rect]; + } + + let currY = 0; + while (currY < height) { + const tileHeight = + currY + ROWS_PER_TILE <= height + ? // use ROWS_PER_TILE if this tile has at least that many rows + ROWS_PER_TILE + : // otherwse use remaining rows + height - currY; + + result.push({ + x, + y: y + currY, + width, + height: tileHeight, + }); + + currY += ROWS_PER_TILE; + } + + return result; +} diff --git a/x-pack/platform/plugins/shared/screenshotting/server/layouts/preserve_layout.ts b/x-pack/platform/plugins/shared/screenshotting/server/layouts/preserve_layout.ts index 79bf7d0f9b852..61c6011706c21 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/layouts/preserve_layout.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/layouts/preserve_layout.ts @@ -12,13 +12,21 @@ import type { Layout } from '.'; import { BaseLayout } from './base_layout'; import type { PageSizeParams, PdfImageSize } from './base_layout'; +// We default to a zoom of two to bump up the resolution of the screenshot a bit. +// However, Chromium/Skia has a height limit of 16384px, so for anything larger +// than 8000, we should use a zoom of one. +// https://github.com/puppeteer/puppeteer/issues/359 +const DEFAULT_ZOOM = 2; +const MAX_HEIGHT_ZOOMED = 8000; + // We use a zoom of two to bump up the resolution of the screenshot a bit. -const ZOOM: number = 2; +export const ZOOM: number = 2; export class PreserveLayout extends BaseLayout implements Layout { public readonly selectors: LayoutSelectorDictionary; public readonly height: number; public readonly width: number; + private readonly zoom: number; private readonly scaledHeight: number; private readonly scaledWidth: number; private imageSize: PdfImageSize = { height: 0, width: 0 }; @@ -27,8 +35,9 @@ export class PreserveLayout extends BaseLayout implements Layout { super('preserve_layout'); this.height = size.height; this.width = size.width; - this.scaledHeight = size.height * ZOOM; - this.scaledWidth = size.width * ZOOM; + this.zoom = this.height <= MAX_HEIGHT_ZOOMED ? DEFAULT_ZOOM : 1; + this.scaledHeight = size.height * this.zoom; + this.scaledWidth = size.width * this.zoom; this.selectors = { ...DEFAULT_SELECTORS, ...selectors }; } @@ -46,7 +55,7 @@ export class PreserveLayout extends BaseLayout implements Layout { } public getBrowserZoom() { - return ZOOM; + return this.zoom; } public getViewport() { diff --git a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.test.ts index 93480379d75ad..4c93a70454078 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.test.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.test.ts @@ -12,6 +12,7 @@ import type { Layout } from '../layouts'; import { createMockLayout } from '../layouts/mock'; import { EventLogger } from './event_logger'; import { getScreenshots } from './get_screenshots'; +import type { MockedLogger } from '@kbn/logging-mocks'; describe('getScreenshots', () => { const elementsPositionAndAttributes = [ @@ -34,11 +35,13 @@ describe('getScreenshots', () => { let eventLogger: EventLogger; let config = {} as ConfigType; let layout: Layout; + let logger: MockedLogger; beforeEach(async () => { browser = createMockBrowserDriver(); config = { capture: { zoom: 2 } } as ConfigType; - eventLogger = new EventLogger(loggingSystemMock.createLogger(), config); + logger = loggingSystemMock.createLogger(); + eventLogger = new EventLogger(logger, config); browser.evaluate.mockImplementation(({ fn, args }) => (fn as Function)(...args)); layout = createMockLayout(); }); @@ -99,11 +102,13 @@ describe('getScreenshots', () => { expect(browser.screenshot).toHaveBeenCalledTimes(2); expect(browser.screenshot).toHaveBeenNthCalledWith(1, { + logger, elementPosition: elementsPositionAndAttributes[0].position, layout, error: undefined, }); expect(browser.screenshot).toHaveBeenNthCalledWith(2, { + logger, elementPosition: elementsPositionAndAttributes[1].position, layout, error: undefined, diff --git a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.ts b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.ts index dc19fe96c9b80..137f3fcf08a3f 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/get_screenshots.ts @@ -77,6 +77,7 @@ export const getScreenshots = async ( ); const data = await browser.screenshot({ + logger: kbnLogger, elementPosition: position, layout: options.layout, error: options.error, diff --git a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/screenshots.test.ts b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/screenshots.test.ts index 1e43d9228b0b7..9d5729f2d700e 100644 --- a/x-pack/platform/plugins/shared/screenshotting/server/screenshots/screenshots.test.ts +++ b/x-pack/platform/plugins/shared/screenshotting/server/screenshots/screenshots.test.ts @@ -19,7 +19,10 @@ import type { PngScreenshotOptions } from '..'; import { HeadlessChromiumDriverFactory } from '../browsers'; import { Screenshots } from './screenshots'; -jest.mock('puppeteer'); +jest.mock('puppeteer'); // see __mocks__/puppeteer.ts + +// from __mocks__/puppeteer.ts +const SCREENSHOT_BYTES = new Uint8Array([3, 1, 4, 1, 5]); describe('class Screenshots', () => { let mockConfig: ConfigType; @@ -126,9 +129,7 @@ describe('class Screenshots', () => { const observe = screenshotsInstance.getScreenshots(options); await firstValueFrom(observe).then((captureResult) => { - expect(captureResult.results[0].screenshots[0].data).toEqual( - Buffer.from(`you won't believe this one weird screenshot`, 'base64') - ); + expect(captureResult.results[0].screenshots[0].data).toEqual(Buffer.from(SCREENSHOT_BYTES)); expect(captureResult.results[0].renderErrors).toBe(undefined); expect(captureResult.results[0].error).toBe(undefined); }); diff --git a/yarn.lock b/yarn.lock index 7f1113b4d2a20..c3c77b5d4048f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11648,7 +11648,7 @@ dependencies: pako "^1.0.6" -"@pdf-lib/upng@^1.0.1": +"@pdf-lib/upng@1.0.1", "@pdf-lib/upng@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@pdf-lib/upng/-/upng-1.0.1.tgz#7dc9c636271aca007a9df4deaf2dd7e7960280cb" integrity sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==