diff --git a/__mocks__/@react-pdf/renderer.js b/__mocks__/@react-pdf/renderer.js index c2e957a31ff..241d8e2eebe 100644 --- a/__mocks__/@react-pdf/renderer.js +++ b/__mocks__/@react-pdf/renderer.js @@ -72,12 +72,42 @@ export const Font = { clear: jest.fn(), }; -export const pdf = jest.fn( () => ( { - toBlob: jest.fn( () => - Promise.resolve( +/* + * The layout the mocked `toBlob()` passes to the document's `onRender` + * callback, mirroring the real library's internal layout shape. The deepest + * box bottom is 24 + 476 = 500, so `measurePDFContentHeight` returns 500, + * and the nested section node puts one anchor at absolute top 224 (200 + 24) + * for `extractPDFSectionAnchors`. + */ +export const MOCK_PDF_LAYOUT = { + _INTERNAL__LAYOUT__DATA_: { + children: [ + { + children: [ + { box: { top: 24, height: 176 } }, + { + box: { top: 200, height: 300 }, + children: [ + { + props: { id: 'section-mockArea' }, + box: { top: 24, height: 276 }, + }, + ], + }, + { box: { top: 24, height: 476 } }, + ], + }, + ], + }, +}; + +export const pdf = jest.fn( ( element ) => ( { + toBlob: jest.fn( () => { + element?.props?.onRender?.( MOCK_PDF_LAYOUT ); + return Promise.resolve( new Blob( [ 'mock-pdf' ], { type: 'application/pdf' } ) - ) - ), + ); + } ), toBuffer: jest.fn( () => Promise.resolve( Buffer.from( 'mock-pdf' ) ) ), toString: jest.fn( () => Promise.resolve( 'mock-pdf' ) ), updateContainer: jest.fn(), diff --git a/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx b/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx index 90db505b5d8..30a82c24a7f 100644 --- a/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx +++ b/assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx @@ -51,6 +51,8 @@ import { waitFor, } from '@tests/js/test-utils'; import { registerPDFFonts } from './pdf-fonts-react'; +import { PDF_MEASURE_PAGE_HEIGHT, PDF_PAGE_BOTTOM_PADDING } from './pdf-theme'; +import { triggerDownload } from './pdf-utils'; import PDFExportOrchestrator from './PDFExportOrchestrator'; import { SECTION_ICONS } from './section-icons'; import { PDFHeaderSection, PDFReportArea } from './types'; @@ -80,6 +82,32 @@ function NullComponent() { return null; } +// The bottom edge of the layout fixture the mocked `toBlob()` passes to +// `onRender` (see `MOCK_PDF_LAYOUT` in `__mocks__/@react-pdf/renderer.js`). +const MOCKED_MEASURED_HEIGHT = 500; + +/** + * Builds a `pdf()` implementation whose `toBlob()` fires the document's + * `onRender` callback with the given layout, overriding the mock's fixture. + * + * @since n.e.x.t + * + * @param layout The layout to pass to `onRender`. + * @return The `pdf()` implementation. + */ +function pdfImplementationWithLayout( layout: unknown ) { + return ( element: { + props?: { onRender?: ( renderedLayout: unknown ) => void }; + } ) => ( { + toBlob: () => { + element?.props?.onRender?.( layout ); + return Promise.resolve( + new Blob( [ 'mock-pdf' ], { type: 'application/pdf' } ) + ); + }, + } ); +} + describe( 'PDFExportOrchestrator', () => { const ADMIN_URL = 'http://example.com/wp-admin/'; let registry: ReturnType< typeof createTestRegistry >; @@ -109,6 +137,7 @@ describe( 'PDFExportOrchestrator', () => { // call that the test still needs to check. ( pdf as jest.Mock ).mockClear(); jest.mocked( registerPDFFonts ).mockClear(); + jest.mocked( triggerDownload ).mockClear(); mockTrackEvent.mockClear(); // Put the real `AbortController` back after a test replaced it with the @@ -296,7 +325,130 @@ describe( 'PDFExportOrchestrator', () => { expect( dates.compareStartDate ).toBeDefined(); expect( signal ).toBeInstanceOf( AbortSignal ); + // A measurement pass and a final pass. + expect( pdf ).toHaveBeenCalledTimes( 2 ); + } ); + + it( 'sizes the final page to the measured content height plus the bottom padding', async () => { + const getData: jest.Mock = jest.fn( () => + Promise.resolve( { data: { totalUsers: 100 } } ) + ); + registerPDFWidget( 'trafficArea', 'trafficWidget', getData ); + registry.dispatch( CORE_PDF ).setSelection( { + contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ], + widgetSlugs: [ 'trafficWidget' ], + } ); + + renderOrchestrator(); + + await waitFor( () => { + expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' ); + } ); + + expect( pdf ).toHaveBeenCalledTimes( 2 ); + + const measurementPass = ( pdf as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( measurementPass.props.pageHeight ).toBe( + PDF_MEASURE_PAGE_HEIGHT + ); + expect( measurementPass.props.onRender ).toEqual( + expect.any( Function ) + ); + + const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ]; + expect( finalPass.props.pageHeight ).toBe( + MOCKED_MEASURED_HEIGHT + PDF_PAGE_BOTTOM_PADDING + ); + expect( finalPass.props.onRender ).toBeUndefined(); + } ); + + it( 'passes the section anchors extracted from the measurement pass to the final pass', async () => { + const getData: jest.Mock = jest.fn( () => + Promise.resolve( { data: { totalUsers: 100 } } ) + ); + registerPDFWidget( 'trafficArea', 'trafficWidget', getData ); + registry.dispatch( CORE_PDF ).setSelection( { + contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ], + widgetSlugs: [ 'trafficWidget' ], + } ); + + renderOrchestrator(); + + await waitFor( () => { + expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' ); + } ); + + const measurementPass = ( pdf as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( measurementPass.props.sectionAnchors ).toBeUndefined(); + + // The section node in the mock layout sits at 200 + 24 = 224. + const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ]; + expect( finalPass.props.sectionAnchors ).toEqual( [ + { id: 'section-mockArea', top: 224 }, + ] ); + } ); + + it( 'caps the final page height at the measurement page height', async () => { + ( pdf as jest.Mock ).mockImplementationOnce( + pdfImplementationWithLayout( { + _INTERNAL__LAYOUT__DATA_: { + children: [ + { + children: [ + { + box: { + top: 0, + height: PDF_MEASURE_PAGE_HEIGHT, + }, + }, + ], + }, + ], + }, + } ) + ); + + const getData: jest.Mock = jest.fn( () => + Promise.resolve( { data: { totalUsers: 100 } } ) + ); + registerPDFWidget( 'trafficArea', 'trafficWidget', getData ); + registry.dispatch( CORE_PDF ).setSelection( { + contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ], + widgetSlugs: [ 'trafficWidget' ], + } ); + + renderOrchestrator(); + + await waitFor( () => { + expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' ); + } ); + + const finalPass = ( pdf as jest.Mock ).mock.calls[ 1 ][ 0 ]; + expect( finalPass.props.pageHeight ).toBe( PDF_MEASURE_PAGE_HEIGHT ); + } ); + + it( 'transitions to error and skips the final pass when the layout measurement fails', async () => { + ( pdf as jest.Mock ).mockImplementationOnce( + pdfImplementationWithLayout( { unexpected: 'shape' } ) + ); + + const getData: jest.Mock = jest.fn( () => + Promise.resolve( { data: { totalUsers: 100 } } ) + ); + registerPDFWidget( 'trafficArea', 'trafficWidget', getData ); + registry.dispatch( CORE_PDF ).setSelection( { + contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ], + widgetSlugs: [ 'trafficWidget' ], + } ); + + renderOrchestrator(); + + await waitFor( () => { + expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'error' ); + } ); + expect( pdf ).toHaveBeenCalledTimes( 1 ); + expect( triggerDownload ).not.toHaveBeenCalled(); } ); it( 'should transition to error and not build a PDF when the only widget fails', async () => { @@ -340,7 +492,7 @@ describe( 'PDFExportOrchestrator', () => { expect( failing ).toHaveBeenCalledTimes( 1 ); expect( succeeding ).toHaveBeenCalledTimes( 1 ); - expect( pdf ).toHaveBeenCalledTimes( 1 ); + expect( pdf ).toHaveBeenCalledTimes( 2 ); } ); it( 'includes only the checked widget when the user unchecks the other widget in the same section', async () => { diff --git a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx index 396abb54395..62770f66e14 100644 --- a/assets/js/components/pdf-export/PDFExportOrchestrator.tsx +++ b/assets/js/components/pdf-export/PDFExportOrchestrator.tsx @@ -51,12 +51,20 @@ import useViewContext from '@/js/hooks/useViewContext'; import useViewOnly from '@/js/hooks/useViewOnly'; import { getPreviousDate, trackEvent } from '@/js/util'; import { ORDERED_MAIN_DASHBOARD_CONTEXTS } from './constants'; +import extractPDFSectionAnchors from './extract-pdf-section-anchors'; +import measurePDFContentHeight from './measure-pdf-content-height'; import { registerPDFFonts } from './pdf-fonts-react'; +import { PDF_MEASURE_PAGE_HEIGHT, PDF_PAGE_BOTTOM_PADDING } from './pdf-theme'; import { getPDFFilename, triggerDownload } from './pdf-utils'; import { WidgetWithPDF, isActivePDFWidget } from './pdf-widget-eligibility'; import { SECTION_ICONS } from './section-icons'; import DashboardReport from './shared-react-pdf-components/DashboardReport'; -import { PDFHeaderSection, PDFReportArea, PDFReportWidget } from './types'; +import { + PDFHeaderSection, + PDFReportArea, + PDFReportWidget, + PDFSectionAnchor, +} from './types'; const STAGE_IDLE = 'IDLE' as const; const STAGE_LOADING = 'LOADING' as const; @@ -80,7 +88,7 @@ const VALID_TRANSITIONS: Record< Stage, readonly Stage[] > = { }; const LOADING_TIMEOUT_MS = 45 * 1000; -const BUILDING_TIMEOUT_MS = 15 * 1000; +const BUILDING_TIMEOUT_MS = 30 * 1000; const COMPLETE_UNMOUNT_DELAY_MS = 2 * 1000; const BLOB_REVOKE_DELAY_MS = 30 * 1000; // Progress budget reserved for the data-loading stage; BUILDING fills the rest. @@ -570,24 +578,77 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( { resolvedDateRange ); - const document = ( + const reportProps = { + siteName: reportSiteName, + siteURL: referenceSiteURL || '', + dashboardURL: dashboardURL || '', + dateRange: { + startDate: dates.startDate, + endDate: dates.endDate, + }, + sections, + helpCenterURL: + 'https://sitekit.withgoogle.com/support/?doc=get-support', + privacyPolicyURL: 'https://policies.google.com/privacy', + areas, + emailReportingSetupURL, + }; + + /* + * The report renders twice: a discarded measurement pass + * captures the content height and the sections' absolute + * positions via `onRender`, then the final pass renders the + * page bounded to the measured height, with the header chips' + * anchor targets pinned at those positions. + */ + let measuredHeight = 0; + let sectionAnchors: PDFSectionAnchor[] = []; + // `@react-pdf` runs `onRender` inside its own render + // pipeline, so an error thrown there may never leave + // `toBlob()`. Capture it and rethrow it here instead. + let measureError: unknown = null; + + await pdf( { + try { + measuredHeight = + measurePDFContentHeight( layout ); + sectionAnchors = + extractPDFSectionAnchors( layout ); + } catch ( error ) { + measureError = error; + } } } - sections={ sections } - helpCenterURL="https://sitekit.withgoogle.com/support/?doc=get-support" - privacyPolicyURL="https://policies.google.com/privacy" - areas={ areas } - emailReportingSetupURL={ emailReportingSetupURL } /> + ).toBlob(); + + throwIfAborted( signal ); + + if ( measureError ) { + throw measureError; + } + + if ( measuredHeight <= 0 ) { + throw new Error( + 'The PDF measurement pass produced no layout.' + ); + } + + const finalPageHeight = Math.min( + measuredHeight + PDF_PAGE_BOTTOM_PADDING, + PDF_MEASURE_PAGE_HEIGHT ); - const blob = await pdf( document ).toBlob(); + const blob = await pdf( + + ).toBlob(); throwIfAborted( signal ); diff --git a/assets/js/components/pdf-export/extract-pdf-section-anchors.test.ts b/assets/js/components/pdf-export/extract-pdf-section-anchors.test.ts new file mode 100644 index 00000000000..af1591bf966 --- /dev/null +++ b/assets/js/components/pdf-export/extract-pdf-section-anchors.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for extractPDFSectionAnchors. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import extractPDFSectionAnchors from './extract-pdf-section-anchors'; + +function buildLayout( pageChildren: unknown ) { + return { + _INTERNAL__LAYOUT__DATA_: { + children: [ { box: { top: 0 }, children: pageChildren } ], + }, + }; +} + +describe( 'extractPDFSectionAnchors', () => { + it( 'returns each section id with its absolute top, summing the offsets down the tree', () => { + const layout = buildLayout( [ + { box: { top: 24, height: 100 } }, + { + box: { top: 148, height: 550 }, + children: [ + { + props: { id: 'section-mainDashboardTraffic' }, + box: { top: 0, height: 200 }, + }, + { + props: { id: 'section-mainDashboardContent' }, + box: { top: 250, height: 300 }, + }, + ], + }, + ] ); + + expect( extractPDFSectionAnchors( layout ) ).toEqual( [ + { id: 'section-mainDashboardTraffic', top: 148 }, + { id: 'section-mainDashboardContent', top: 398 }, + ] ); + } ); + + it( 'ignores nodes whose id does not carry the section prefix', () => { + const layout = buildLayout( [ + { props: { id: 'header' }, box: { top: 24, height: 100 } }, + { + props: { id: 'section-mainDashboardTraffic' }, + box: { top: 148, height: 200 }, + }, + ] ); + + expect( extractPDFSectionAnchors( layout ) ).toEqual( [ + { id: 'section-mainDashboardTraffic', top: 148 }, + ] ); + } ); + + it( 'treats a node without a box as adding no offset', () => { + const layout = buildLayout( [ + { + children: [ + { + props: { id: 'section-mainDashboardTraffic' }, + box: { top: 148, height: 200 }, + }, + ], + }, + ] ); + + expect( extractPDFSectionAnchors( layout ) ).toEqual( [ + { id: 'section-mainDashboardTraffic', top: 148 }, + ] ); + } ); + + it.each( [ + [ 'the layout is undefined', undefined ], + [ 'the layout is null', null ], + [ 'the internal layout data is missing', {} ], + [ + 'the layout holds no pages', + { _INTERNAL__LAYOUT__DATA_: { children: [] } }, + ], + [ + 'the page holds no sections', + buildLayout( [ { box: { top: 24 } } ] ), + ], + ] )( 'returns an empty list when %s', ( _description, layout ) => { + expect( extractPDFSectionAnchors( layout ) ).toEqual( [] ); + } ); +} ); diff --git a/assets/js/components/pdf-export/extract-pdf-section-anchors.ts b/assets/js/components/pdf-export/extract-pdf-section-anchors.ts new file mode 100644 index 00000000000..7442788a9f5 --- /dev/null +++ b/assets/js/components/pdf-export/extract-pdf-section-anchors.ts @@ -0,0 +1,89 @@ +/** + * Extracts section anchor positions from a `@react-pdf/renderer` layout. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import { PDFSectionAnchor } from './types'; + +interface LayoutNodeShape { + box?: { top?: unknown }; + props?: { id?: unknown }; + children?: unknown; +} + +export const PDF_SECTION_ID_PREFIX = 'section-'; + +function walk( + node: LayoutNodeShape, + parentTop: number, + anchors: PDFSectionAnchor[] +): void { + const relativeTop = typeof node.box?.top === 'number' ? node.box.top : 0; + const top = parentTop + relativeTop; + + const id = node.props?.id; + if ( typeof id === 'string' && id.startsWith( PDF_SECTION_ID_PREFIX ) ) { + anchors.push( { id, top } ); + } + + if ( Array.isArray( node.children ) ) { + node.children.forEach( ( child ) => + walk( child as LayoutNodeShape, top, anchors ) + ); + } +} + +/** + * Collects each section node's `id` and absolute top from the measurement + * pass layout. + * + * `@react-pdf` registers a named destination from a node's parent-relative + * top, so an `id` deep in the tree lands the viewer too high. The final + * render pass instead places zero-size anchors directly on the page, at the + * absolute tops this function reads by summing each node's offset down the + * tree. + * + * Returns an empty list when the layout holds no section nodes; the shape + * itself is guarded by `measurePDFContentHeight`, which runs on the same + * layout first. + * + * @since n.e.x.t + * + * @param layout The value passed to the `Document` `onRender` callback. + * @return The section anchors in document order. + */ +export default function extractPDFSectionAnchors( + layout: unknown +): PDFSectionAnchor[] { + const layoutData = ( + layout as { _INTERNAL__LAYOUT__DATA_?: { children?: unknown } } + )?._INTERNAL__LAYOUT__DATA_; + + const children = layoutData?.children; + const page = Array.isArray( children ) ? children[ 0 ] : undefined; + + if ( ! page ) { + return []; + } + + const anchors: PDFSectionAnchor[] = []; + walk( page as LayoutNodeShape, 0, anchors ); + + return anchors; +} diff --git a/assets/js/components/pdf-export/measure-pdf-content-height.test.ts b/assets/js/components/pdf-export/measure-pdf-content-height.test.ts new file mode 100644 index 00000000000..3243bc5fd3a --- /dev/null +++ b/assets/js/components/pdf-export/measure-pdf-content-height.test.ts @@ -0,0 +1,93 @@ +/** + * Tests for measurePDFContentHeight. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Internal dependencies + */ +import measurePDFContentHeight from './measure-pdf-content-height'; + +const EXPECTED_PATH = + 'layout._INTERNAL__LAYOUT__DATA_.children[0].children[].box.{top,height}'; + +function buildLayout( children: unknown ) { + return { + _INTERNAL__LAYOUT__DATA_: { + children: [ { children } ], + }, + }; +} + +describe( 'measurePDFContentHeight', () => { + it( 'returns the maximum bottom edge (top + height) across the page children', () => { + const layout = buildLayout( [ + { box: { top: 0, height: 120 } }, + { box: { top: 150, height: 700 } }, + { box: { top: 500, height: 100 } }, + ] ); + + expect( measurePDFContentHeight( layout ) ).toBe( 850 ); + } ); + + it( 'returns the bottom edge of a single child', () => { + const layout = buildLayout( [ { box: { top: 24, height: 476 } } ] ); + + expect( measurePDFContentHeight( layout ) ).toBe( 500 ); + } ); + + it.each( [ + [ 'the layout is undefined', undefined ], + [ 'the layout is null', null ], + [ 'the internal layout data is missing', {} ], + [ + 'the internal layout data has no children array', + { _INTERNAL__LAYOUT__DATA_: {} }, + ], + [ + 'the first page is missing', + { _INTERNAL__LAYOUT__DATA_: { children: [] } }, + ], + [ + 'the first page has no children array', + { _INTERNAL__LAYOUT__DATA_: { children: [ {} ] } }, + ], + [ 'the first page has no children', buildLayout( [] ) ], + [ + 'a child has no box', + buildLayout( [ { box: { top: 0, height: 100 } }, {} ] ), + ], + [ + 'a child box has no numeric top', + buildLayout( [ { box: { height: 100 } } ] ), + ], + [ + 'a child box has no numeric height', + buildLayout( [ { box: { top: 100 } } ] ), + ], + [ + 'the computed height is non-positive', + buildLayout( [ { box: { top: 0, height: 0 } } ] ), + ], + ] )( + 'throws an error naming the expected property path when %s', + ( _description, layout ) => { + expect( () => measurePDFContentHeight( layout ) ).toThrow( + EXPECTED_PATH + ); + } + ); +} ); diff --git a/assets/js/components/pdf-export/measure-pdf-content-height.ts b/assets/js/components/pdf-export/measure-pdf-content-height.ts new file mode 100644 index 00000000000..f2a6e930eb4 --- /dev/null +++ b/assets/js/components/pdf-export/measure-pdf-content-height.ts @@ -0,0 +1,87 @@ +/** + * Measures the rendered content height of a `@react-pdf/renderer` layout. + * + * Site Kit by Google, Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface LayoutBox { + top: number; + height: number; +} + +interface LayoutNode { + box?: LayoutBox; +} + +const SHAPE_ERROR = + '@react-pdf/renderer internal layout shape unrecognised: expected layout._INTERNAL__LAYOUT__DATA_.children[0].children[].box.{top,height}. Did the package version change?'; + +/** + * Reads the bottom edge of the lowest node on the first rendered page. + * + * The layout comes from the `onRender` callback of a `@react-pdf/renderer` + * `Document`. Its `_INTERNAL__LAYOUT__DATA_` value is undocumented, so the + * function throws when the expected shape is absent rather than sizing the + * page from a bad value: a clean export failure the user can retry, not a + * broken PDF. + * + * @since n.e.x.t + * + * @param layout The value passed to the `Document` `onRender` callback. + * @return The content height in points. + */ +export default function measurePDFContentHeight( layout: unknown ): number { + const layoutData = ( + layout as { _INTERNAL__LAYOUT__DATA_?: { children?: unknown } } + )?._INTERNAL__LAYOUT__DATA_; + + if ( ! layoutData || ! Array.isArray( layoutData.children ) ) { + throw new Error( SHAPE_ERROR ); + } + + const page = layoutData.children[ 0 ] as { children?: unknown } | undefined; + + if ( ! page || ! Array.isArray( page.children ) ) { + throw new Error( SHAPE_ERROR ); + } + + const children = page.children as LayoutNode[]; + + if ( children.length === 0 ) { + throw new Error( SHAPE_ERROR ); + } + + let maxBottom = 0; + + for ( const child of children ) { + const { box } = child; + + if ( + ! box || + typeof box.top !== 'number' || + typeof box.height !== 'number' + ) { + throw new Error( SHAPE_ERROR ); + } + + maxBottom = Math.max( maxBottom, box.top + box.height ); + } + + if ( maxBottom <= 0 ) { + throw new Error( SHAPE_ERROR ); + } + + return maxBottom; +} diff --git a/assets/js/components/pdf-export/pdf-theme.ts b/assets/js/components/pdf-export/pdf-theme.ts index 80578d5540b..dc87d8c2ff5 100644 --- a/assets/js/components/pdf-export/pdf-theme.ts +++ b/assets/js/components/pdf-export/pdf-theme.ts @@ -41,6 +41,24 @@ export const PDF_FONT_FAMILY_DISPLAY = 'GoogleSansDisplay'; */ export const PDF_FONT_FAMILY_TEXT = 'GoogleSansText'; +/** + * The page height, in points, for the measurement render pass. + * + * Tall enough to hold any report on one page without pagination, while + * staying under the PDF specification's hard page-size limit. + * + * @since n.e.x.t + */ +export const PDF_MEASURE_PAGE_HEIGHT = 14400; + +/** + * The padding, in points, added below the measured content height when + * sizing the final single page. + * + * @since n.e.x.t + */ +export const PDF_PAGE_BOTTOM_PADDING = 60; + /** * Shared colors for the PDF report. * diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.test.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.test.tsx index 495d5b10517..562eed5312a 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.test.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.test.tsx @@ -30,6 +30,10 @@ import { PDF_PAGE_WIDTH, scalePDFValue, } from '@/js/components/pdf-export/pdf-scale'; +import { + PDF_MEASURE_PAGE_HEIGHT, + PDF_PAGE_BOTTOM_PADDING, +} from '@/js/components/pdf-export/pdf-theme'; import { SECTION_ICONS } from '@/js/components/pdf-export/section-icons'; import { PDFWidgetComponentProps } from '@/js/components/pdf-export/types'; import { CONTEXT_MAIN_DASHBOARD_TRAFFIC } from '@/js/googlesitekit/widgets/default-contexts'; @@ -292,9 +296,21 @@ describe( 'DashboardReport', () => { const reportJSON = renderDashboardReportJSON(); expect( reportJSON ).toContain( `"padding":${ PDF_PAGE_PADDING }` ); + expect( reportJSON ).toContain( + `"paddingBottom":${ PDF_PAGE_BOTTOM_PADDING }` + ); expect( reportJSON ).toContain( `"size":[${ PDF_PAGE_WIDTH },` ); } ); + it( 'sizes the page to the measurement height by default and to the given pageHeight otherwise', () => { + expect( renderDashboardReportJSON() ).toContain( + `"size":[${ PDF_PAGE_WIDTH },${ PDF_MEASURE_PAGE_HEIGHT }]` + ); + expect( renderDashboardReportJSON( { pageHeight: 1234 } ) ).toContain( + `"size":[${ PDF_PAGE_WIDTH },1234]` + ); + } ); + it( "declares the document's language from the locale", () => { const reportJSON = renderDashboardReportJSON(); @@ -358,6 +374,78 @@ describe( 'DashboardReport', () => { expect( reportJSON ).toContain( '"bookmark":"Traffic"' ); } ); + it( 'anchors each section with its prefixed area slug', () => { + const areas = [ + { + areaSlug: 'mainDashboardTrafficPrimary', + areaTitle: 'Traffic', + widgets: [ + { + slug: 'analyticsAllTrafficGA4', + Component: FakeWidget, + data: 'visitors', + }, + ], + }, + ]; + + const { container } = renderDashboardReport( { areas } ); + + expect( + container.querySelector( + '[id="section-mainDashboardTrafficPrimary"]' + ) + ).toBeInTheDocument(); + } ); + + it( 'renders page-level anchors and unanchored sections when section anchors are given', () => { + const areas = [ + { + areaSlug: 'mainDashboardTrafficPrimary', + areaTitle: 'Traffic', + widgets: [ + { + slug: 'analyticsAllTrafficGA4', + Component: FakeWidget, + data: 'visitors', + }, + ], + }, + ]; + const sectionAnchors = [ + { id: 'section-mainDashboardTrafficPrimary', top: 224 }, + ]; + + const { container } = renderDashboardReport( { + areas, + sectionAnchors, + } ); + + // Only the page-level anchor carries the id, pinned at the section's + // absolute top; the section view itself renders without an id. + const anchored = container.querySelectorAll( + '[id="section-mainDashboardTrafficPrimary"]' + ); + expect( anchored ).toHaveLength( 1 ); + expect( anchored[ 0 ] ).toHaveStyle( { + position: 'absolute', + top: '224px', + } ); + } ); + + it( 'forwards the onRender callback to the document', () => { + const onRender = jest.fn(); + + const testRenderer = TestRenderer.create( + + ); + const documentNode = testRenderer.root.findByProps( { + author: 'Example Site', + } ); + + expect( documentNode.props.onRender ).toBe( onRender ); + } ); + it( 'scales the gap between an area title and its first widget', () => { const areas = [ { diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.tsx index ad77730e152..571f740e3de 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/DashboardReport.tsx @@ -39,20 +39,21 @@ import { import { PDF_COLORS, PDF_FONT_FAMILY_TEXT, + PDF_MEASURE_PAGE_HEIGHT, + PDF_PAGE_BOTTOM_PADDING, } from '@/js/components/pdf-export/pdf-theme'; import PDFFooter from '@/js/components/pdf-export/shared-react-pdf-components/PDFFooter'; import { PDFHeaderSection, PDFReportArea, PDFReportWidget, + PDFSectionAnchor, } from '@/js/components/pdf-export/types'; import { getLocale } from '@/js/util/i18n'; import PDFEmailReportingNotice from './PDFEmailReportingNotice'; import PDFHeader from './PDFHeader'; import PDFTypography from './PDFTypography'; -const DEFAULT_PAGE_HEIGHT = 792; - /** * Formats a date range for the document title, e.g. "Jan 1, 2021 - Jan 28, 2021". * @@ -146,8 +147,12 @@ export interface DashboardReportProps { helpCenterURL: string; /** Golink URL opening the Google privacy policy, for the footer. */ privacyPolicyURL: string; - /** The page height in points. Defaults to the US letter height. */ + /** The page height in points. Defaults to the measurement-pass height. */ pageHeight?: number; + /** Receives the `@react-pdf` layout result once the document renders. */ + onRender?: ( layout: unknown ) => void; + /** Page-level anchors from the measurement pass. When given, they carry the section anchor ids instead of the section views. */ + sectionAnchors?: PDFSectionAnchor[]; /** The report areas, each holding its widgets. */ areas?: PDFReportArea[]; /** Golink URL for the "Set up email reports" button in the email reporting notice. */ @@ -162,7 +167,9 @@ const DashboardReport: FC< DashboardReportProps > = ( { sections, helpCenterURL, privacyPolicyURL, - pageHeight = DEFAULT_PAGE_HEIGHT, + pageHeight = PDF_MEASURE_PAGE_HEIGHT, + onRender, + sectionAnchors, areas = [], emailReportingSetupURL, } ) => { @@ -208,12 +215,44 @@ const DashboardReport: FC< DashboardReportProps > = ( { keywords={ __( 'Site Kit, Google, report', 'google-site-kit' ) } language={ getLocale() } pageMode="useOutlines" + onRender={ onRender } > + { /* + * `@react-pdf` sizes a `wrap={false}` page to its content, so the + * page ends at the footer plus this bottom padding; the explicit + * height is an upper bound, not the rendered size. + */ } + { /* + * `@react-pdf` registers a named destination from a node's + * parent-relative top, so only a direct page child anchors at + * its true position. The measurement pass reads each section's + * absolute top from the layout, and the final pass pins these + * zero-size anchors there in the sections' place. + */ } + { ( sectionAnchors || [] ).map( ( { id, top } ) => ( + + ) ) } = ( { ( { areaSlug, areaTitle, widgets } ) => ( { ); } ); + it( 'links each chip to its section anchor within the document', () => { + const { getByText } = renderPDFHeader(); + + sections.forEach( ( { slug, label } ) => { + expect( getByText( label ).closest( 'pdf-link' ) ).toHaveAttribute( + 'src', + `#section-${ slug }` + ); + } ); + } ); + it( 'renders the "View dashboard in Site Kit" link to the dashboard URL', () => { const { getByText } = renderPDFHeader(); diff --git a/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx b/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx index 8985e1680a2..891fbe86ed6 100644 --- a/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx +++ b/assets/js/components/pdf-export/shared-react-pdf-components/PDFHeader.tsx @@ -19,7 +19,7 @@ /** * External dependencies */ -import { Path, View } from '@react-pdf/renderer'; +import { Link, Path, View } from '@react-pdf/renderer'; import { FC } from 'react'; /** @@ -120,6 +120,9 @@ const styles = createPDFStyles( { chipWrapper: { marginRight: 24, }, + chipLink: { + textDecoration: 'none', + }, viewDashboard: { flexShrink: 0, marginLeft: 8, @@ -202,7 +205,12 @@ const PDFHeader: FC< PDFHeaderProps > = ( { { sections.map( ( { slug, label, Icon } ) => ( - + + + ) ) } diff --git a/assets/js/components/pdf-export/types.ts b/assets/js/components/pdf-export/types.ts index 5f6e3c68c75..c42163e78d6 100644 --- a/assets/js/components/pdf-export/types.ts +++ b/assets/js/components/pdf-export/types.ts @@ -69,6 +69,17 @@ export interface PDFHeaderSection { Icon?: PDFIcon; } +/** + * A named-destination anchor for one report section: the anchor `id` the + * header chips link to, and the section's absolute top on the page in points. + * + * @since n.e.x.t + */ +export interface PDFSectionAnchor { + id: string; + top: number; +} + /** * A loaded PDF widget ready to render into the report document. * diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-darwin.png index 4f99ebf6b53..575d171c2f9 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-linux.png index 46ee3270734..f3bb9a95903 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-desktop-linux.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-darwin.png index 4f99ebf6b53..575d171c2f9 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-linux.png index 46ee3270734..f3bb9a95903 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-downloads-the-AdSense-monetization-section-on-its-own-1-chrome-mobile-linux.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-darwin.png index abd2d799131..5d152eb11c4 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-linux.png index 6128f42d05c..ae2faec3c4d 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-desktop-linux.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-darwin.png index abd2d799131..5d152eb11c4 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-linux.png index 6128f42d05c..ae2faec3c4d 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-generates-a-report-and-downloads-a-PDF-golden-path-1-chrome-mobile-linux.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-darwin.png index a27424bc987..bb80599cb93 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-linux.png index 9a5586014fe..2b29689d990 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-desktop-linux.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-darwin.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-darwin.png index a27424bc987..bb80599cb93 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-darwin.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-darwin.png differ diff --git a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-linux.png b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-linux.png index 9a5586014fe..2b29689d990 100644 Binary files a/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-linux.png and b/tests/playwright/specs/pdf-generation/pdf-generation.spec.ts-snapshots/PDF-Generation-still-completes-and-downloads-when-one-section-fails-1-chrome-mobile-linux.png differ