Skip to content

Commit aff875e

Browse files
committed
Resolve merge conflicts.
2 parents 35348c5 + ede750c commit aff875e

57 files changed

Lines changed: 2971 additions & 393 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/js/components/pdf-export/PDFExportOrchestrator.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
render,
3737
waitFor,
3838
} from '@tests/js/test-utils';
39+
import { registerPDFFonts } from './pdf-fonts-react';
3940
import PDFExportOrchestrator from './PDFExportOrchestrator';
4041

4142
// Stub the download trigger so the anchor click does not attempt a JSDOM
@@ -45,6 +46,10 @@ jest.mock( './pdf-utils', () => ( {
4546
triggerDownload: jest.fn(),
4647
} ) );
4748

49+
jest.mock( './pdf-fonts-react', () => ( {
50+
registerPDFFonts: jest.fn(),
51+
} ) );
52+
4853
function NullComponent() {
4954
return null;
5055
}
@@ -60,6 +65,7 @@ describe( 'PDFExportOrchestrator', () => {
6065
registry.dispatch( CORE_USER ).setDateRange( 'last-28-days' );
6166

6267
( pdf as jest.Mock ).mockClear();
68+
jest.mocked( registerPDFFonts ).mockClear();
6369

6470
global.URL.createObjectURL = jest.fn( () => 'blob:mock-url' );
6571
global.URL.revokeObjectURL = jest.fn();
@@ -162,4 +168,48 @@ describe( 'PDFExportOrchestrator', () => {
162168
expect( succeeding ).toHaveBeenCalledTimes( 1 );
163169
expect( pdf ).toHaveBeenCalledTimes( 1 );
164170
} );
171+
172+
it( 'registers the PDF fonts before rendering the document', async () => {
173+
const getData: jest.Mock = jest.fn( () =>
174+
Promise.resolve( { data: { totalUsers: 100 } } )
175+
);
176+
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
177+
registry.dispatch( CORE_PDF ).setSelection( {
178+
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
179+
widgetSlugs: [],
180+
} );
181+
182+
renderOrchestrator();
183+
184+
await waitFor( () => {
185+
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'success' );
186+
} );
187+
188+
expect( registerPDFFonts ).toHaveBeenCalledTimes( 1 );
189+
expect(
190+
jest.mocked( registerPDFFonts ).mock.invocationCallOrder[ 0 ]
191+
).toBeLessThan( ( pdf as jest.Mock ).mock.invocationCallOrder[ 0 ] );
192+
} );
193+
194+
it( 'transitions to error and does not build a PDF when font registration fails', async () => {
195+
jest.mocked( registerPDFFonts ).mockImplementationOnce( () => {
196+
throw new Error( 'font registration failed' );
197+
} );
198+
const getData: jest.Mock = jest.fn( () =>
199+
Promise.resolve( { data: { totalUsers: 100 } } )
200+
);
201+
registerPDFWidget( 'trafficArea', 'trafficWidget', getData );
202+
registry.dispatch( CORE_PDF ).setSelection( {
203+
contextSlugs: [ CONTEXT_MAIN_DASHBOARD_TRAFFIC ],
204+
widgetSlugs: [],
205+
} );
206+
207+
renderOrchestrator();
208+
209+
await waitFor( () => {
210+
expect( registry.select( CORE_PDF ).getStatus() ).toBe( 'error' );
211+
} );
212+
213+
expect( pdf ).not.toHaveBeenCalled();
214+
} );
165215
} );

assets/js/components/pdf-export/PDFExportOrchestrator.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import type {
4848
} from '@/js/googlesitekit/widgets/types';
4949
import useViewOnly from '@/js/hooks/useViewOnly';
5050
import { getPreviousDate } from '@/js/util';
51+
import { registerPDFFonts } from './pdf-fonts-react';
5152
import { getPDFFilename, triggerDownload } from './pdf-utils';
5253
import DashboardReport from './shared-react-pdf-components/DashboardReport';
5354
import type { PDFReportArea, PDFReportWidget } from './types';
@@ -423,6 +424,9 @@ const PDFExportOrchestrator: FC< PDFExportOrchestratorProps > = ( {
423424
dispatch( { type: 'TRANSITION', nextStage: STAGE_BUILDING } );
424425
armStageTimeout( BUILDING_TIMEOUT_MS );
425426

427+
registerPDFFonts();
428+
throwIfAborted( signal );
429+
426430
const areas: PDFReportArea[] = discoveredAreas.map(
427431
( area ) => ( {
428432
areaSlug: area.areaSlug,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# PDF brand fonts
2+
3+
`registerPDFFonts()` (`../pdf-fonts-react.ts`) embeds these Google Sans binaries
4+
into the generated PDF. Only the weights the PDF design uses are bundled.
5+
6+
| File | Internal family | Weight |
7+
| --- | --- | --- |
8+
| `google-sans-display-regular.ttf` | Google Sans Display | 400 |
9+
| `google-sans-text-regular.ttf` | Google Sans | 400 |
10+
| `google-sans-text-medium.ttf` | Google Sans | 500 |
11+
12+
The display family (headings / large sizes) uses Google Sans Display; the text
13+
family (body / captions) uses Google Sans. The `@react-pdf` family labels are
14+
assigned in `../pdf-theme.ts` (`PDF_FONT_FAMILY_DISPLAY` / `PDF_FONT_FAMILY_TEXT`),
15+
so the binaries' internal names do not need to match those labels.
16+
17+
Source: the Flutter gallery-assets font set
18+
(`https://flutter.googlesource.com/gallery-assets/+/refs/heads/master/lib/fonts/`).
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* Tests for PDF font registration.
3+
*
4+
* Site Kit by Google, Copyright 2026 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
/**
20+
* Internal dependencies
21+
*/
22+
import { PDF_FONT_FAMILY_DISPLAY, PDF_FONT_FAMILY_TEXT } from './pdf-theme';
23+
24+
// `@react-pdf/renderer` is auto-mocked via `__mocks__/@react-pdf/renderer.js`.
25+
// The module under test holds a session-scoped registration latch, so each test
26+
// reloads it (and the mocked renderer) with `jest.resetModules()`.
27+
async function setup() {
28+
const { Font } = await import( '@react-pdf/renderer' );
29+
const { registerPDFFonts } = await import( './pdf-fonts-react' );
30+
return { Font, registerPDFFonts };
31+
}
32+
33+
// The bundled (multi-weight) shape we always register with; narrows the
34+
// `SingleLoad | BulkLoad` union the typings expose for `Font.register`.
35+
type FontConfig = {
36+
family: string;
37+
fonts: Array< { src: string; fontWeight: number } >;
38+
};
39+
40+
describe( 'registerPDFFonts', () => {
41+
beforeEach( () => {
42+
jest.resetModules();
43+
jest.clearAllMocks();
44+
} );
45+
46+
it( 'registers both families with the bundled weights and returns the display family', async () => {
47+
const { Font, registerPDFFonts } = await setup();
48+
49+
const family = registerPDFFonts();
50+
51+
expect( family ).toBe( PDF_FONT_FAMILY_DISPLAY );
52+
expect( Font.register ).toHaveBeenCalledTimes( 2 );
53+
54+
const calls = jest.mocked( Font.register ).mock.calls as Array<
55+
[ FontConfig ]
56+
>;
57+
const displayConfig = calls.find(
58+
( [ config ] ) => config.family === PDF_FONT_FAMILY_DISPLAY
59+
)?.[ 0 ];
60+
const textConfig = calls.find(
61+
( [ config ] ) => config.family === PDF_FONT_FAMILY_TEXT
62+
)?.[ 0 ];
63+
64+
expect( displayConfig?.fonts.map( ( f ) => f.fontWeight ) ).toEqual( [
65+
400,
66+
] );
67+
expect( textConfig?.fonts.map( ( f ) => f.fontWeight ) ).toEqual( [
68+
400, 500,
69+
] );
70+
} );
71+
72+
it( 'registers URL strings (not data URIs) as the font src', async () => {
73+
const { Font, registerPDFFonts } = await setup();
74+
75+
registerPDFFonts();
76+
77+
const calls = jest.mocked( Font.register ).mock.calls as Array<
78+
[ FontConfig ]
79+
>;
80+
const sources = calls
81+
.flatMap( ( [ config ] ) => config.fonts )
82+
.map( ( { src } ) => src );
83+
84+
expect( sources ).toHaveLength( 3 );
85+
sources.forEach( ( src ) => {
86+
expect( typeof src ).toBe( 'string' );
87+
expect( src.startsWith( 'data:' ) ).toBe( false );
88+
} );
89+
} );
90+
91+
it( 'registers a hyphenation callback that returns the whole word', async () => {
92+
const { Font, registerPDFFonts } = await setup();
93+
94+
registerPDFFonts();
95+
96+
expect( Font.registerHyphenationCallback ).toHaveBeenCalledTimes( 1 );
97+
const callback = jest.mocked( Font.registerHyphenationCallback ).mock
98+
.calls[ 0 ][ 0 ];
99+
expect( callback( 'visitors' ) ).toEqual( [ 'visitors' ] );
100+
} );
101+
102+
it( 'is idempotent within a session', async () => {
103+
const { Font, registerPDFFonts } = await setup();
104+
105+
registerPDFFonts();
106+
jest.mocked( Font.register ).mockClear();
107+
jest.mocked( Font.registerHyphenationCallback ).mockClear();
108+
109+
registerPDFFonts();
110+
111+
expect( Font.register ).not.toHaveBeenCalled();
112+
expect( Font.registerHyphenationCallback ).not.toHaveBeenCalled();
113+
} );
114+
115+
it( 'propagates registration errors instead of falling back', async () => {
116+
const { Font, registerPDFFonts } = await setup();
117+
118+
jest.mocked( Font.register ).mockImplementationOnce( () => {
119+
throw new Error( 'register failed' );
120+
} );
121+
122+
expect( () => registerPDFFonts() ).toThrow( 'register failed' );
123+
} );
124+
} );
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* PDF font registration for @react-pdf/renderer.
3+
*
4+
* Site Kit by Google, Copyright 2026 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
/**
20+
* External dependencies
21+
*/
22+
import { Font } from '@react-pdf/renderer';
23+
24+
/**
25+
* Internal dependencies
26+
*/
27+
import googleSansDisplayRegular from './fonts/google-sans-display-regular.ttf';
28+
import googleSansTextMedium from './fonts/google-sans-text-medium.ttf';
29+
import googleSansTextRegular from './fonts/google-sans-text-regular.ttf';
30+
import { PDF_FONT_FAMILY_DISPLAY, PDF_FONT_FAMILY_TEXT } from './pdf-theme';
31+
32+
let fontsRegistered = false;
33+
34+
/**
35+
* Registers Site Kit's brand fonts with @react-pdf/renderer.
36+
*
37+
* Embeds Google Sans Display (400) and Google Sans Text (400, 500) so the
38+
* generated PDF renders in the dashboard's typography. The font binaries are
39+
* emitted as hashed static assets by webpack and fetched by @react-pdf at
40+
* render time (not when this function runs), so the dashboard's page load is
41+
* unaffected. Registration is idempotent for the lifetime of the session.
42+
*
43+
* Errors are intentionally not caught: a failure propagates so the orchestrator
44+
* transitions to its ERROR stage rather than silently rendering in a fallback
45+
* typeface.
46+
*
47+
* @since n.e.x.t
48+
*
49+
* @return {string} The display font family name.
50+
*/
51+
export function registerPDFFonts() {
52+
if ( fontsRegistered ) {
53+
return PDF_FONT_FAMILY_DISPLAY;
54+
}
55+
56+
Font.register( {
57+
family: PDF_FONT_FAMILY_DISPLAY,
58+
fonts: [ { src: googleSansDisplayRegular, fontWeight: 400 } ],
59+
} );
60+
61+
Font.register( {
62+
family: PDF_FONT_FAMILY_TEXT,
63+
fonts: [
64+
{ src: googleSansTextRegular, fontWeight: 400 },
65+
{ src: googleSansTextMedium, fontWeight: 500 },
66+
],
67+
} );
68+
69+
// Disable hyphenation so words wrap whole rather than being split.
70+
Font.registerHyphenationCallback( ( word ) => [ word ] );
71+
72+
fontsRegistered = true;
73+
74+
return PDF_FONT_FAMILY_DISPLAY;
75+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Shared theme constants for the PDF export (@react-pdf/renderer).
3+
*
4+
* Site Kit by Google, Copyright 2026 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License");
7+
* you may not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* https://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS,
14+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
* See the License for the specific language governing permissions and
16+
* limitations under the License.
17+
*/
18+
19+
/**
20+
* Registered @react-pdf font family for display sizes and headings.
21+
*
22+
* Maps to Google Sans Display (regular / 400 only).
23+
*/
24+
export const PDF_FONT_FAMILY_DISPLAY = 'GoogleSansDisplay';
25+
26+
/**
27+
* Registered @react-pdf font family for body text and captions.
28+
*
29+
* Maps to Google Sans Text (regular / 400 and medium / 500).
30+
*/
31+
export const PDF_FONT_FAMILY_TEXT = 'GoogleSansText';

0 commit comments

Comments
 (0)