[Reporting/Dashboard] Use Chromium for print-optimized PDFs (#130546)

* first version of semi-sane results

* getting a bit more sophisticated

* wip on footer, page numbers not working, but logo working

* re-work PoC for readability, added a lot of comments

* change up formatting for readability

* added comment

* remove some comments and remove HACK

* use page.pdf function

* remove controls from shared PoC ui

* preserveDrawingBuffer fix for maps, needs review

* minor clean up

* update sass

* clean up experimental code

* moved a few files around to get this ready for review

* added appservices as print media code owners

* [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix'

* added PDFJS to get num pages

* fix getting page number using pdfjs-dist

* update inline snapshot

* Revert "update inline snapshot"

This reverts commit eb413234a7.

* do not create a new page at the very end

* major overhaul, rather use puppeteers footerTemplate and headerTemplate to get visual parity with current reports

* add TODO

* update test fixture

* update doc comment

* remove whitespace

* fix missing time range from print PDF header and make size much smaller

* update tests

* update test

* try out slash instead of nbsp

* Revert "try out slash instead of nbsp"

This reverts commit 1de112a6f5.

* implement ability to inject logo using handlebars templates

* move assets to shared location

* fix injecting of values via handlebars and minor style tweaks for 3rd party logos

* inject a few more values to the footer

* update casing check

* use locales version of headless chromium zip

* fix tests and update sizing of logos

* use locales version for arm64 too

* fix jest test

* fix types

* made pdf capture check stricter

* fix PDF generation issue due to query bar rendering content that caused an issue; need to figure out what exactly the issue was...

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2022-05-10 08:35:04 +02:00 committed by GitHub
parent c46b457d36
commit 9a78d3dde4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 567 additions and 120 deletions

1
.github/CODEOWNERS vendored
View file

@ -83,6 +83,7 @@
/x-pack/plugins/ui_actions_enhanced/ @elastic/kibana-app-services
/x-pack/plugins/runtime_fields @elastic/kibana-app-services
/x-pack/test/search_sessions_integration/ @elastic/kibana-app-services
/src/plugins/dashboard/public/application/embeddable/viewport/print_media @elastic/kibana-app-services
### Observability Plugins

View file

@ -339,6 +339,7 @@
"p-retry": "^4.2.0",
"papaparse": "^5.2.0",
"pbf": "3.2.1",
"pdfjs-dist": "^2.13.216",
"pdfmake": "^0.2.4",
"peggy": "^1.2.0",
"pluralize": "3.1.0",

View file

@ -146,10 +146,10 @@ export const TEMPORARILY_IGNORED_PATHS = [
'x-pack/plugins/monitoring/public/icons/health-green.svg',
'x-pack/plugins/monitoring/public/icons/health-red.svg',
'x-pack/plugins/monitoring/public/icons/health-yellow.svg',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Medium.ttf',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/noto/NotoSansCJKtc-Regular.ttf',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Italic.ttf',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Medium.ttf',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/fonts/roboto/Roboto-Regular.ttf',
'x-pack/plugins/screenshotting/server/formats/pdf/pdf_maker/assets/img/logo-grey.png',
'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Medium.ttf',
'x-pack/plugins/screenshotting/server/assets/fonts/noto/NotoSansCJKtc-Regular.ttf',
'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Italic.ttf',
'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Medium.ttf',
'x-pack/plugins/screenshotting/server/assets/fonts/roboto/Roboto-Regular.ttf',
'x-pack/plugins/screenshotting/server/assets/img/logo-grey.png',
];

View file

@ -119,13 +119,12 @@ export function DashboardApp({
<>
{isCompleteDashboardAppState(dashboardAppState) && (
<>
{!printMode && (
<DashboardTopNav
redirectTo={redirectTo}
embedSettings={embedSettings}
dashboardAppState={dashboardAppState}
/>
)}
<DashboardTopNav
printMode={printMode}
redirectTo={redirectTo}
embedSettings={embedSettings}
dashboardAppState={dashboardAppState}
/>
{dashboardAppState.savedDashboard.outcome === 'conflict' &&
dashboardAppState.savedDashboard.id &&

View file

@ -1,9 +1,68 @@
.printViewport {
&__vis {
height: 600px; // These values might need to be passed in as dimensions for the report. I.e., print should use layout dimensions.
width: 975px;
@import './print_media/styling/index';
// Some vertical space between vis, but center horizontally
margin: 10px auto;
$visualisationsPerPage: 2;
$visPadding: 4mm;
/*
We set the same visual padding on the browser and print versions of the UI so that
we don't hit a race condition where padding is being updated while the print image
is being formed. This can result in parts of the vis being cut out.
*/
@mixin visualizationPadding {
// Open space from page margin
padding-left: $visPadding;
padding-right: $visPadding;
// Last vis on the page
&:nth-child(#{$visualisationsPerPage}n) {
page-break-after: always;
padding-top: $visPadding;
padding-bottom: $visPadding;
}
&:last-child {
page-break-after: avoid;
}
}
@media screen, projection {
.printViewport {
&__vis {
@include visualizationPadding();
& .embPanel__header button {
display: none;
}
margin: $euiSizeL auto;
height: calc(#{$a4PageContentHeight} / #{$visualisationsPerPage});
width: $a4PageContentWidth;
padding: $visPadding;
}
}
}
@media print {
.printViewport {
&__vis {
@include visualizationPadding();
height: calc(#{$a4PageContentHeight} / #{$visualisationsPerPage});
width: $a4PageContentWidth;
& .euiPanel {
box-shadow: none !important;
}
& .embPanel__header button {
display: none;
}
page-break-inside: avoid;
& * {
overflow: hidden !important;
}
}
}
}

View file

@ -0,0 +1,7 @@
# Print media
The code here is designed to be movable outside the domain of Dashboard. Currently,
the components and styles are only used by Dashboard but we may choose to move them to,
for example, a Kibana package in the future.
Any changes to this code must be tested by generating a print-optimized PDF in dashboard.

View file

@ -0,0 +1,52 @@
@import './vars';
/*
This styling contains utility and minimal layout styles to help plugins create
print-ready HTML.
Observations:
1. We currently do not control the user-agent's header and footer content
(including the style of fonts) for client-side printing.
2. Page box model is quite different from what we have in browsers - page
margins define where the "no-mans-land" exists for actual content. Moving
content into this space by, for example setting negative margins resulted
in slightly unpredictable behaviour because the browser wants to either
move this content to another page or it may get split across two
pages.
3. page-break-* is your friend!
*/
// Currently we cannot control or style the content the browser places in
// margins, this might change in the future:
// See https://drafts.csswg.org/css-page-3/#margin-boxes
@page {
size: A4;
orientation: portrait;
margin: 0;
margin-top: $a4PageHeaderHeight;
margin-bottom: $a4PageFooterHeight;
}
@media print {
html {
background-color: #FFF;
}
// It is good practice to show the full URL in the final, printed output
a[href]:after {
content: ' [' attr(href) ']';
}
figure {
page-break-inside: avoid;
}
* {
-webkit-print-color-adjust: exact !important; /* Chrome, Safari, Edge */
color-adjust: exact !important; /*Firefox*/
}
}

View file

@ -0,0 +1,10 @@
$a4PageHeight: 297mm;
$a4PageWidth: 210mm;
$a4PageMargin: 0;
$a4PagePadding: 0;
$a4PageHeaderHeight: 15mm;
$a4PageFooterHeight: 20mm;
$a4PageContentHeight: $a4PageHeight - $a4PageHeaderHeight - $a4PageFooterHeight;
$a4PageContentWidth: $a4PageWidth;

View file

@ -183,8 +183,7 @@ export const useDashboardAppState = ({
savedDashboard,
});
// Backwards compatible way of detecting that we are taking a screenshot
const legacyPrintLayoutDetected =
const printLayoutDetected =
screenshotModeService?.isScreenshotMode() &&
screenshotModeService.getScreenshotContext('layout') === 'print';
@ -194,8 +193,7 @@ export const useDashboardAppState = ({
...initialDashboardStateFromUrl,
...forwardedAppState,
// if we are in legacy print mode, dashboard needs to be in print viewMode
...(legacyPrintLayoutDetected ? { viewMode: ViewMode.PRINT } : {}),
...(printLayoutDetected ? { viewMode: ViewMode.PRINT } : {}),
// if there is an incoming embeddable, dashboard always needs to be in edit mode to receive it.
...(incomingEmbeddable ? { viewMode: ViewMode.EDIT } : {}),

View file

@ -82,6 +82,7 @@ export interface DashboardTopNavProps {
dashboardAppState: CompleteDashboardAppState;
embedSettings?: DashboardEmbedSettings;
redirectTo: DashboardRedirect;
printMode: boolean;
}
const LabsFlyout = withSuspense(LazyLabsFlyout, null);
@ -90,6 +91,7 @@ export function DashboardTopNav({
dashboardAppState,
embedSettings,
redirectTo,
printMode,
}: DashboardTopNavProps) {
const {
core,
@ -488,7 +490,9 @@ export function DashboardTopNav({
const isFullScreenMode = dashboardState.fullScreenMode;
const showTopNavMenu = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowTopNavMenu));
const showQueryInput = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowQueryInput));
const showQueryInput = shouldShowNavBarComponent(
Boolean(embedSettings?.forceShowQueryInput || printMode)
);
const showDatePicker = shouldShowNavBarComponent(Boolean(embedSettings?.forceShowDatePicker));
const showFilterBar = shouldShowFilterBar(Boolean(embedSettings?.forceHideFilterBar));
const showQueryBar = showQueryInput || showDatePicker || showFilterBar;
@ -535,6 +539,7 @@ export function DashboardTopNav({
useDefaultBehaviors: true,
savedQuery: state.savedQuery,
savedQueryId: dashboardState.savedQuery,
visible: printMode !== true,
onQuerySubmit: (_payload, isUpdate) => {
if (isUpdate === false) {
dashboardAppState.$triggerDashboardRefresh.next({ force: true });
@ -585,10 +590,10 @@ export function DashboardTopNav({
return (
<>
<TopNavMenu {...getNavBarProps()} />
{isLabsEnabled && isLabsShown ? (
{!printMode && isLabsEnabled && isLabsShown ? (
<LabsFlyout solutions={['dashboard']} onClose={() => setIsLabsShown(false)} />
) : null}
{dashboardState.viewMode !== ViewMode.VIEW ? (
{dashboardState.viewMode !== ViewMode.VIEW && !printMode ? (
<>
<EuiHorizontalRule margin="none" />
<SolutionToolbar isDarkModeEnabled={IS_DARK_THEME}>

View file

@ -4,6 +4,12 @@
}
}
.kbnTopNavMenu__wrapper {
&--hidden {
display: none;
}
}
.kbnTopNavMenu__badgeWrapper {
display: flex;
align-items: baseline;

View file

@ -28,6 +28,7 @@ export type TopNavMenuProps = StatefulSearchBarProps &
showFilterBar?: boolean;
unifiedSearch?: UnifiedSearchPublicPluginStart;
className?: string;
visible?: boolean;
/**
* If provided, the menu part of the component will be rendered as a portal inside the given mount point.
*
@ -105,9 +106,11 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null {
}
function renderLayout() {
const { setMenuMountPoint } = props;
const { setMenuMountPoint, visible } = props;
const menuClassName = classNames('kbnTopNavMenu', props.className);
const wrapperClassName = 'kbnTopNavMenu__wrapper';
const wrapperClassName = classNames('kbnTopNavMenu__wrapper', {
'kbnTopNavMenu__wrapper--hidden': visible === false,
});
if (setMenuMountPoint) {
return (
<>

View file

@ -9,7 +9,7 @@
},
"server": true,
"ui": true,
"requiredPlugins": ["dataViews", "data", "uiActions"],
"requiredPlugins": ["dataViews", "data", "uiActions", "screenshotMode"],
"requiredBundles": ["kibanaUtils", "kibanaReact", "data"],
"serviceFolders": ["autocomplete"],
"configPath": ["unifiedSearch"]

View file

@ -55,7 +55,7 @@ export class UnifiedSearchPublicPlugin
public start(
core: CoreStart,
{ data, dataViews, uiActions }: UnifiedSearchStartDependencies
{ data, dataViews, uiActions, screenshotMode }: UnifiedSearchStartDependencies
): UnifiedSearchPublicPluginStart {
setTheme(core.theme);
setOverlays(core.overlays);
@ -68,6 +68,7 @@ export class UnifiedSearchPublicPlugin
data,
storage: this.storage,
usageCollection: this.usageCollection,
isScreenshotMode: Boolean(screenshotMode?.isScreenshotMode()),
});
uiActions.addTriggerAction(

View file

@ -87,6 +87,7 @@ export interface QueryBarTopRowProps {
filterBar?: React.ReactNode;
showDatePickerAsBadge?: boolean;
showSubmitButton?: boolean;
isScreenshotMode?: boolean;
}
const SharingMetaFields = React.memo(function SharingMetaFields({
@ -474,6 +475,8 @@ export const QueryBarTopRow = React.memo(
);
}
const isScreenshotMode = props.isScreenshotMode === true;
return (
<>
<SharingMetaFields
@ -481,25 +484,29 @@ export const QueryBarTopRow = React.memo(
to={currentDateRange.to}
dateFormat={uiSettings.get('dateFormat')}
/>
<EuiFlexGroup
className="kbnQueryBar"
direction={isMobile && !shouldShowDatePickerAsBadge() ? 'column' : 'row'}
responsive={false}
gutterSize="s"
justifyContent={shouldShowDatePickerAsBadge() ? 'flexStart' : 'flexEnd'}
wrap
>
{renderDataViewsPicker()}
<EuiFlexItem
grow={!shouldShowDatePickerAsBadge()}
style={{ minWidth: shouldShowDatePickerAsBadge() ? 'auto' : 320 }}
>
{renderQueryInput()}
</EuiFlexItem>
{shouldShowDatePickerAsBadge() && props.filterBar}
{renderUpdateButton()}
</EuiFlexGroup>
{!shouldShowDatePickerAsBadge() && props.filterBar}
{!isScreenshotMode && (
<>
<EuiFlexGroup
className="kbnQueryBar"
direction={isMobile && !shouldShowDatePickerAsBadge() ? 'column' : 'row'}
responsive={false}
gutterSize="s"
justifyContent={shouldShowDatePickerAsBadge() ? 'flexStart' : 'flexEnd'}
wrap
>
{renderDataViewsPicker()}
<EuiFlexItem
grow={!shouldShowDatePickerAsBadge()}
style={{ minWidth: shouldShowDatePickerAsBadge() ? 'auto' : 320 }}
>
{renderQueryInput()}
</EuiFlexItem>
{shouldShowDatePickerAsBadge() && props.filterBar}
{renderUpdateButton()}
</EuiFlexGroup>
{!shouldShowDatePickerAsBadge() && props.filterBar}
</>
)}
</>
);
},

View file

@ -26,6 +26,7 @@ interface StatefulSearchBarDeps {
data: Omit<DataPublicPluginStart, 'ui'>;
storage: IStorageWrapper;
usageCollection?: UsageCollectionSetup;
isScreenshotMode?: boolean;
}
export type StatefulSearchBarProps = SearchBarOwnProps & {
@ -110,7 +111,13 @@ const overrideDefaultBehaviors = (props: StatefulSearchBarProps) => {
return props.useDefaultBehaviors ? {} : props;
};
export function createSearchBar({ core, storage, data, usageCollection }: StatefulSearchBarDeps) {
export function createSearchBar({
core,
storage,
data,
usageCollection,
isScreenshotMode = false,
}: StatefulSearchBarDeps) {
// App name should come from the core application service.
// Until it's available, we'll ask the user to provide it for the pre-wired component.
return (props: StatefulSearchBarProps) => {
@ -197,6 +204,7 @@ export function createSearchBar({ core, storage, data, usageCollection }: Statef
{...overrideDefaultBehaviors(props)}
dataViewPickerComponentProps={props.dataViewPickerComponentProps}
displayStyle={props.displayStyle}
isScreenshotMode={isScreenshotMode}
/>
</KibanaContextProvider>
);

View file

@ -20,5 +20,8 @@ export const searchBarStyles = ({ euiTheme }: UseEuiTheme) => {
inPage: css`
padding: 0;
`,
hidden: css`
display: none;
`,
};
};

View file

@ -87,6 +87,7 @@ export interface SearchBarOwnProps {
fillSubmitButton?: boolean;
dataViewPickerComponentProps?: DataViewPickerProps;
showSubmitButton?: boolean;
isScreenshotMode?: boolean;
}
export type SearchBarProps = SearchBarOwnProps & SearchBarInjectedDeps;
@ -341,13 +342,16 @@ class SearchBarUI extends Component<SearchBarProps & WithEuiThemeProps, State> {
public render() {
const { theme } = this.props;
const isScreenshotMode = this.props.isScreenshotMode === true;
const styles = searchBarStyles(theme);
const cssStyles = [
styles.uniSearchBar,
this.props.displayStyle && styles[this.props.displayStyle],
isScreenshotMode && styles.hidden,
];
const classes = classNames('uniSearchBar', {
[`uniSearchBar--hidden`]: isScreenshotMode,
[`uniSearchBar--${this.props.displayStyle}`]: this.props.displayStyle,
});
@ -470,6 +474,7 @@ class SearchBarUI extends Component<SearchBarProps & WithEuiThemeProps, State> {
dataViewPickerComponentProps={this.props.dataViewPickerComponentProps}
showDatePickerAsBadge={this.shouldShowDatePickerAsBadge()}
filterBar={filterBar}
isScreenshotMode={this.props.isScreenshotMode}
/>
</div>
);

View file

@ -8,6 +8,7 @@
import type { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import type { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
@ -29,6 +30,7 @@ export interface UnifiedSearchStartDependencies {
fieldFormats: FieldFormatsStart;
data: DataPublicPluginStart;
uiActions: UiActionsStart;
screenshotMode?: ScreenshotModePluginStart;
}
/**

View file

@ -25,7 +25,8 @@
"mapsEms",
"savedObjects",
"share",
"presentationUtil"
"presentationUtil",
"screenshotMode"
],
"optionalPlugins": [
"cloud",

View file

@ -41,6 +41,7 @@ import type { SecurityPluginStart } from '@kbn/security-plugin/public';
import type { SpacesPluginStart } from '@kbn/spaces-plugin/public';
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { LensPublicSetup } from '@kbn/lens-plugin/public';
import { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public';
import {
createRegionMapFn,
regionMapRenderer,
@ -88,6 +89,7 @@ export interface MapsPluginSetupDependencies {
share: SharePluginSetup;
licensing: LicensingPluginSetup;
usageCollection?: UsageCollectionSetup;
screenshotMode: ScreenshotModePluginSetup;
}
export interface MapsPluginStartDependencies {
@ -144,7 +146,15 @@ export class MapsPlugin
registerLicensedFeatures(plugins.licensing);
const config = this._initializerContext.config.get<MapsConfigType>();
setMapAppConfig(config);
setMapAppConfig({
...config,
// Override this when we know we are taking a screenshot (i.e. no user interaction)
// to avoid a blank-canvas issue when rendering maps on a PDF
preserveDrawingBuffer: plugins.screenshotMode.isScreenshotMode()
? true
: config.preserveDrawingBuffer,
});
const locator = plugins.share.url.locators.create(
new MapsAppLocatorDefinition({

View file

@ -33,6 +33,7 @@
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/shared_ux/tsconfig.json" },
{ "path": "../../../src/plugins/screenshot_mode/tsconfig.json" },
{ "path": "../cloud/tsconfig.json" },
{ "path": "../features/tsconfig.json" },
{ "path": "../lens/tsconfig.json" },

View file

@ -17,6 +17,7 @@ import {
import { ConfigType } from '../../config';
import { allowRequest } from '../network_policy';
import { stripUnsafeHeaders } from './strip_unsafe_headers';
import { getFooterTemplate, getHeaderTemplate } from './templates';
export type Context = Record<string, unknown>;
@ -155,6 +156,18 @@ export class HeadlessChromiumDriver {
return !this.page.isClosed();
}
async printA4Pdf({ title, logo }: { title: string; logo?: string }): Promise<Buffer> {
return this.page.pdf({
format: 'a4',
preferCSSPageSize: true,
scale: 1,
landscape: false,
displayHeaderFooter: true,
headerTemplate: await getHeaderTemplate({ title }),
footerTemplate: await getFooterTemplate({ logo }),
});
}
/*
* Call Page.screenshot and return a base64-encoded string of the image
*/

View file

@ -67,8 +67,8 @@ export class ChromiumArchivePaths {
{
platform: 'linux',
architecture: 'x64',
archiveFilename: 'chromium-70f5d88-linux_x64.zip',
archiveChecksum: '7b1c9c2fb613444fbdf004a3b75a58df',
archiveFilename: 'chromium-70f5d88-locales-linux_x64.zip',
archiveChecksum: '759bda5e5d32533cb136a85e37c0d102',
binaryChecksum: '82e80f9727a88ba3836ce230134bd126',
binaryRelativePath: 'headless_shell-linux_x64/headless_shell',
location: 'custom',
@ -78,8 +78,8 @@ export class ChromiumArchivePaths {
{
platform: 'linux',
architecture: 'arm64',
archiveFilename: 'chromium-70f5d88-linux_arm64.zip',
archiveChecksum: '4a0217cfe7da86ad1e3d0e9e5895ddb5',
archiveFilename: 'chromium-70f5d88-locales-linux_arm64.zip',
archiveChecksum: '33613b8dc5212c0457210d5a37ea4b43',
binaryChecksum: '29e943fbee6d87a217abd6cb6747058e',
binaryRelativePath: 'headless_shell-linux_arm64/headless_shell',
location: 'custom',

View file

@ -0,0 +1,47 @@
<style>
div.container {
position: relative;
font-size: 10px;
font-family: system-ui;
text-align: center;
color: #aaa;
width: 100%;
}
div.pages {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 3mm;
text-align: center;
}
img.logo {
position: absolute;
left: 2mm;
bottom: 3mm;
overflow: hidden;
}
div.poweredByElastic {
position: absolute;
color: #aaa;
font-size: 2mm;
left: 2mm;
bottom: 0;
}
</style>
<div class="container">
<!-- DPI is 72/96 so scaling of all values here must * 0.75 to get values on page -->
<img class="logo" height="30mm" width="80mm" src="{{base64FooterLogo}}" />
{{#if hasCustomLogo}}
<div class="poweredByElastic">{{poweredByElasticCopy}}</div>
{{/if}}
<div class="pages">
<span class="pageNumber"></span>&nbsp;of&nbsp;<span class="totalPages"></span>
</div>
</div>

View file

@ -0,0 +1,10 @@
<style>
.myTitle {
font-size: 8px;
color: #aaa;
font-family: system-ui;
text-align: center;
width: 100%;
}
</style>
<span class="myTitle">{{title}}</span>

View file

@ -0,0 +1,63 @@
/*
* 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 { i18n } from '@kbn/i18n';
import fs from 'fs/promises';
import path from 'path';
import Handlebars from 'handlebars';
import { assetPath } from '../../../constants';
async function compileTemplate<T>(pathToTemplate: string): Promise<Handlebars.TemplateDelegate<T>> {
const contentsBuffer = await fs.readFile(pathToTemplate);
return Handlebars.compile(contentsBuffer.toString());
}
interface HeaderTemplateInput {
title: string;
}
interface GetHeaderArgs {
title: string;
}
export async function getHeaderTemplate({ title }: GetHeaderArgs): Promise<string> {
const template = await compileTemplate<HeaderTemplateInput>(
path.resolve(__dirname, './header.handlebars.html')
);
return template({ title });
}
async function getDefaultFooterLogo(): Promise<string> {
const logoBuffer = await fs.readFile(path.resolve(assetPath, 'img', 'logo-grey.png'));
return `data:image/png;base64,${logoBuffer.toString('base64')}`;
}
interface FooterTemplateInput {
base64FooterLogo: string;
hasCustomLogo: boolean;
poweredByElasticCopy: string;
}
interface GetFooterArgs {
logo?: string;
}
export async function getFooterTemplate({ logo }: GetFooterArgs): Promise<string> {
const template = await compileTemplate<FooterTemplateInput>(
path.resolve(__dirname, './footer.handlebars.html')
);
const hasCustomLogo = Boolean(logo);
return template({
base64FooterLogo: hasCustomLogo ? logo! : await getDefaultFooterLogo(),
hasCustomLogo,
poweredByElasticCopy: i18n.translate(
'xpack.screenshotting.exportTypes.printablePdf.footer.logoDescription',
{
defaultMessage: 'Powered by Elastic',
}
),
});
}

View file

@ -88,11 +88,11 @@ describe('ensureDownloaded', () => {
expect.arrayContaining([
'chrome-mac.zip',
'chrome-win.zip',
'chromium-70f5d88-linux_x64.zip',
'chromium-70f5d88-locales-linux_x64.zip',
])
);
expect(readdirSync(path.resolve(`${paths.archivesPath}/arm64`))).toEqual(
expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-linux_arm64.zip'])
expect.arrayContaining(['chrome-mac.zip', 'chromium-70f5d88-locales-linux_arm64.zip'])
);
});

View file

@ -0,0 +1,10 @@
/*
* 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 path from 'path';
export const assetPath = path.resolve(__dirname, 'assets');

View file

@ -5,6 +5,10 @@
* 2.0.
*/
// FIXME: Once/if we have the ability to get page count directly from Chrome/puppeteer
// we should get rid of this lib.
import * as PDFJS from 'pdfjs-dist/legacy/build/pdf.js';
import type { Values } from '@kbn/utility-types';
import { groupBy } from 'lodash';
import type { PackageInfo } from '@kbn/core/server';
@ -99,30 +103,51 @@ export async function toPdf(
{ logo, title }: PdfScreenshotOptions,
{ metrics, results }: CaptureResult
): Promise<PdfScreenshotResult> {
const timeRange = getTimeRange(results);
try {
const { buffer, pages } = await pngsToPdf({
title: title ? `${title}${timeRange ? ` - ${timeRange}` : ''}` : undefined,
results,
layout,
logo,
packageInfo,
eventLogger,
let buffer: Buffer;
let pages: number;
const shouldConvertPngsToPdf = layout.id !== LayoutTypes.PRINT;
if (shouldConvertPngsToPdf) {
const timeRange = getTimeRange(results);
try {
({ buffer, pages } = await pngsToPdf({
title: title ? `${title}${timeRange ? ` - ${timeRange}` : ''}` : undefined,
results,
layout,
logo,
packageInfo,
eventLogger,
}));
return {
metrics: {
...(metrics ?? {}),
pages,
},
data: buffer,
errors: results.flatMap(({ error }) => (error ? [error] : [])),
renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []),
};
} catch (error) {
eventLogger.kbnLogger.error(`Could not generate the PDF buffer!`);
eventLogger.error(error, Transactions.PDF);
throw error;
}
} else {
buffer = results[0].screenshots[0].data; // This buffer is already the PDF
pages = await PDFJS.getDocument({ data: buffer }).promise.then((doc) => {
const numPages = doc.numPages;
doc.destroy();
return numPages;
});
return {
metrics: {
...(metrics ?? {}),
pages,
},
data: buffer,
errors: results.flatMap(({ error }) => (error ? [error] : [])),
renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []),
};
} catch (error) {
eventLogger.kbnLogger.error(`Could not generate the PDF buffer!`);
eventLogger.error(error, Transactions.PDF);
throw error;
}
return {
metrics: {
...(metrics ?? {}),
pages,
},
data: buffer,
errors: results.flatMap(({ error }) => (error ? [error] : [])),
renderErrors: results.flatMap(({ renderErrors }) => renderErrors ?? []),
};
}

View file

@ -5,9 +5,8 @@
* 2.0.
*/
import path from 'path';
import { assetPath } from '../../../constants';
export const assetPath = path.resolve(__dirname, 'assets');
export const tableBorderWidth = 1;
export const pageMarginTop = 40;
export const pageMarginBottom = 80;
@ -21,3 +20,4 @@ export const subheadingMarginTop = 0;
export const subheadingMarginBottom = 5;
export const subheadingHeight =
subheadingFontSize * 1.5 + subheadingMarginTop + subheadingMarginBottom;
export { assetPath };

View file

@ -83,6 +83,8 @@ export class PdfMaker {
const groupCount = this.content.length;
// inject a page break for every 2 groups on the page
// TODO: Remove this code since we are now using Chromium to drive this
// layout via native print functionality.
if (groupCount > 0 && groupCount % this.layout.groupCount === 0) {
contents = [
{

View file

@ -12,7 +12,7 @@ import { CaptureResult } from '..';
import { PLUGIN_ID } from '../../../common';
import { ConfigType } from '../../config';
import { ElementPosition } from '../get_element_position_data';
import { Screenshot } from '../get_screenshots';
import type { Screenshot } from '../types';
export enum Actions {
OPEN_URL = 'open-url',
@ -25,6 +25,7 @@ export enum Actions {
WAIT_RENDER = 'wait-for-render',
WAIT_VISUALIZATIONS = 'wait-for-visualizations',
GET_SCREENSHOT = 'get-screenshots',
PRINT_A4_PDF = 'print-a4-pdf',
ADD_IMAGE = 'add-pdf-image',
COMPILE = 'compile-pdf',
}

View file

@ -0,0 +1,33 @@
/*
* 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 { Actions, EventLogger } from './event_logger';
import type { HeadlessChromiumDriver } from '../browsers';
import type { Screenshot } from './types';
export async function getPdf(
browser: HeadlessChromiumDriver,
logger: EventLogger,
title: string,
logo?: string
): Promise<Screenshot[]> {
logger.kbnLogger.info('printing PDF');
const spanEnd = logger.logPdfEvent('printing A4 PDF', Actions.PRINT_A4_PDF, 'output');
const result = [
{
data: await browser.printA4Pdf({ title, logo }),
title: null,
description: null,
},
];
spanEnd();
return result;
}

View file

@ -8,23 +8,7 @@
import type { HeadlessChromiumDriver } from '../browsers';
import { Actions, EventLogger } from './event_logger';
import type { ElementsPositionAndAttribute } from './get_element_position_data';
export interface Screenshot {
/**
* Screenshot PNG image data.
*/
data: Buffer;
/**
* Screenshot title.
*/
title: string | null;
/**
* Screenshot description.
*/
description: string | null;
}
import type { Screenshot } from './types';
export const getScreenshots = async (
browser: HeadlessChromiumDriver,

View file

@ -8,7 +8,7 @@
import type { Headers } from '@kbn/core/server';
import { defer, forkJoin, Observable, throwError } from 'rxjs';
import { catchError, mergeMap, switchMapTo, timeoutWith } from 'rxjs/operators';
import { errors } from '../../common';
import { errors, LayoutTypes } from '../../common';
import type { Context, HeadlessChromiumDriver } from '../browsers';
import { DEFAULT_VIEWPORT, getChromiumDisconnectedError } from '../browsers';
import { ConfigType, durationToNumber as toNumber } from '../config';
@ -18,13 +18,15 @@ import type { ElementsPositionAndAttribute } from './get_element_position_data';
import { getElementPositionAndAttributes } from './get_element_position_data';
import { getNumberOfItems } from './get_number_of_items';
import { getRenderErrors } from './get_render_errors';
import type { Screenshot } from './get_screenshots';
import type { Screenshot } from './types';
import { getScreenshots } from './get_screenshots';
import { getPdf } from './get_pdf';
import { getTimeRange } from './get_time_range';
import { injectCustomCss } from './inject_css';
import { openUrl } from './open_url';
import { waitForRenderComplete } from './wait_for_render';
import { waitForVisualizations } from './wait_for_visualizations';
import type { PdfScreenshotOptions } from '../formats';
type CaptureTimeouts = ConfigType['capture']['timeouts'];
export interface PhaseTimeouts extends CaptureTimeouts {
@ -237,6 +239,26 @@ export class ScreenshotObservableHandler {
);
}
/**
* Given a title and time range value look like:
*
* "[Logs] Web Traffic - Apr 14, 2022 @ 120742.318 to Apr 21, 2022 @ 120742.318"
*
* Otherwise closest thing to that or a blank string.
*/
private getTitle(timeRange: null | string): string {
return `${(this.options as PdfScreenshotOptions).title ?? ''} ${
timeRange ? `- ${timeRange}` : ''
}`.trim();
}
private shouldCapturePdf(): boolean {
return (
this.layout.id === LayoutTypes.PRINT &&
(this.options as PdfScreenshotOptions).format === 'pdf'
);
}
public getScreenshots() {
return (withRenderComplete: Observable<PageSetupResults>) =>
withRenderComplete.pipe(
@ -247,7 +269,14 @@ export class ScreenshotObservableHandler {
getDefaultElementPosition(this.layout.getViewport(1));
let screenshots: Screenshot[] = [];
try {
screenshots = await getScreenshots(this.driver, this.eventLogger, elements);
screenshots = this.shouldCapturePdf()
? await getPdf(
this.driver,
this.eventLogger,
this.getTitle(data.timeRange),
(this.options as PdfScreenshotOptions).logo
)
: await getScreenshots(this.driver, this.eventLogger, elements);
} catch (e) {
throw new errors.FailedToCaptureScreenshot(e.message);
}

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
export interface Screenshot {
/**
* Screenshot PNG image data.
*/
data: Buffer;
/**
* Screenshot title.
*/
title: string | null;
/**
* Screenshot description.
*/
description: string | null;
}

View file

@ -19,7 +19,7 @@ export const PDF_PRESERVE_PIE_VISUALIZATION_6_3 = `/api/reporting/generate/print
)}`;
export const PDF_PRINT_PIE_VISUALIZATION_FILTER_AND_SAVED_SEARCH_6_3 = `/api/reporting/generate/printablePdf?jobParams=${encodeURIComponent(
`(browserTimezone:America/New_York,layout:(id:print),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')`
`(browserTimezone:America/New_York,layout:(dimensions:(height:588,width:1038),id:preserve_layout),objectType:visualization,relativeUrls:!('/app/kibana#/visualize/edit/befdb6b0-3e59-11e8-9fc3-39e49624228e?_g=(refreshInterval:(display:Off,pause:!!f,value:0),time:(from:!'Mon+Apr+09+2018+17:56:08+GMT-0400!',mode:absolute,to:!'Wed+Apr+11+2018+17:56:08+GMT-0400!'))&_a=(filters:!!((!'$state!':(store:appState),meta:(alias:!!n,disabled:!!f,index:a0f483a0-3dc9-11e8-8660-4d65aa086b3c,key:animal.keyword,negate:!!f,params:(query:dog,type:phrase),type:phrase,value:dog),query:(match:(animal.keyword:(query:dog,type:phrase))))),linked:!!t,query:(language:lucene,query:!'!'),uiState:(),vis:(aggs:!!((enabled:!!t,id:!'1!',params:(),schema:metric,type:count),(enabled:!!t,id:!'2!',params:(field:name.keyword,missingBucket:!!f,missingBucketLabel:Missing,order:desc,orderBy:!'1!',otherBucket:!!f,otherBucketLabel:Other,size:5),schema:segment,type:terms)),params:(addLegend:!!t,addTooltip:!!t,isDonut:!!t,labels:(last_level:!!t,show:!!f,truncate:100,values:!!t),legendPosition:right,type:pie),title:!'Filter+Test:+animals:+linked+to+search+with+filter!',type:pie))'),title:'Filter Test: animals: linked to search with filter')`
)}`;
export const JOB_PARAMS_CSV_DEFAULT_SPACE = `/api/reporting/generate/csv_searchsource?jobParams=${encodeURIComponent(

View file

@ -74,15 +74,15 @@ export default function ({ getService }: FtrProviderContext) {
const usage = await usageAPI.getUsageStats();
reportingAPI.expectRecentPdfAppStats(usage, 'visualization', 1);
reportingAPI.expectRecentPdfAppStats(usage, 'dashboard', 1);
reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 0);
reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 2);
reportingAPI.expectRecentPdfLayoutStats(usage, 'preserve_layout', 1);
reportingAPI.expectRecentPdfLayoutStats(usage, 'print', 1);
reportingAPI.expectRecentJobTypeTotalStats(usage, 'csv_searchsource', 0);
reportingAPI.expectRecentJobTypeTotalStats(usage, 'printable_pdf', 2);
reportingAPI.expectAllTimePdfAppStats(usage, 'visualization', 1);
reportingAPI.expectAllTimePdfAppStats(usage, 'dashboard', 1);
reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 0);
reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 2);
reportingAPI.expectAllTimePdfLayoutStats(usage, 'preserve_layout', 1);
reportingAPI.expectAllTimePdfLayoutStats(usage, 'print', 1);
reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'csv_searchsource', 0);
reportingAPI.expectAllTimeJobTypeTotalStats(usage, 'printable_pdf', 2);
});

View file

@ -101,31 +101,47 @@ export function createUsageServices({ getService }: FtrProviderContext) {
},
expectRecentPdfAppStats(stats: UsageStats, app: string, count: number) {
expect(
stats.reporting.last_7_days.printable_pdf.app![app as keyof AvailableTotal['app']]
).to.be(count);
const actual =
stats.reporting.last_7_days.printable_pdf.app![app as keyof AvailableTotal['app']];
log.info(`expecting recent ${app} stats to have ${count} printable pdfs (actual: ${actual})`);
expect(actual).to.be(count);
},
expectAllTimePdfAppStats(stats: UsageStats, app: string, count: number) {
expect(stats.reporting.printable_pdf.app![app as keyof AvailableTotal['app']]).to.be(count);
const actual = stats.reporting.printable_pdf.app![app as keyof AvailableTotal['app']];
log.info(
`expecting all time pdf ${app} stats to have ${count} printable pdfs (actual: ${actual})`
);
expect(actual).to.be(count);
},
expectRecentPdfLayoutStats(stats: UsageStats, layout: string, count: number) {
expect(stats.reporting.last_7_days.printable_pdf.layout![layout as keyof LayoutCounts]).to.be(
count
);
const actual =
stats.reporting.last_7_days.printable_pdf.layout![layout as keyof LayoutCounts];
log.info(`expecting recent stats to report ${count} ${layout} layouts (actual: ${actual})`);
expect(actual).to.be(count);
},
expectAllTimePdfLayoutStats(stats: UsageStats, layout: string, count: number) {
expect(stats.reporting.printable_pdf.layout![layout as keyof LayoutCounts]).to.be(count);
const actual = stats.reporting.printable_pdf.layout![layout as keyof LayoutCounts];
log.info(`expecting all time stats to report ${count} ${layout} layouts (actual: ${actual})`);
expect(actual).to.be(count);
},
expectRecentJobTypeTotalStats(stats: UsageStats, jobType: string, count: number) {
expect(stats.reporting.last_7_days[jobType as keyof JobTypes].total).to.be(count);
const actual = stats.reporting.last_7_days[jobType as keyof JobTypes].total;
log.info(
`expecting recent stats to report ${count} ${jobType} job types (actual: ${actual})`
);
expect(actual).to.be(count);
},
expectAllTimeJobTypeTotalStats(stats: UsageStats, jobType: string, count: number) {
expect(stats.reporting[jobType as keyof JobTypes].total).to.be(count);
const actual = stats.reporting[jobType as keyof JobTypes].total;
log.info(
`expecting all time stats to report ${count} ${jobType} job types (actual: ${actual})`
);
expect(actual).to.be(count);
},
getCompletedReportCount(stats: UsageStats) {

View file

@ -22202,6 +22202,13 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
pdfjs-dist@^2.13.216:
version "2.13.216"
resolved "https://registry.yarnpkg.com/pdfjs-dist/-/pdfjs-dist-2.13.216.tgz#251a11c9c8c6db19baacd833a4e6986c517d1ab3"
integrity sha512-qn/9a/3IHIKZarTK6ajeeFXBkG15Lg1Fx99PxU09PAU2i874X8mTcHJYyDJxu7WDfNhV6hM7bRQBZU384anoqQ==
dependencies:
web-streams-polyfill "^3.2.0"
pdfmake@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/pdfmake/-/pdfmake-0.2.4.tgz#7d58d64b59f8e9b9ed0b2494b17a9d94c575825b"
@ -29757,6 +29764,11 @@ web-streams-polyfill@^3.0.0:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.0.1.tgz#1f836eea307e8f4af15758ee473c7af755eb879e"
integrity sha512-M+EmTdszMWINywOZaqpZ6VIEDUmNpRaTOuizF0ZKPjSDC8paMRe/jBBwFv0Yeyn5WYnM5pMqMQa82vpaE+IJRw==
web-streams-polyfill@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz#a6b74026b38e4885869fb5c589e90b95ccfc7965"
integrity sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==
webidl-conversions@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"