Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.99% covered (success)
96.99%
129 / 133
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Google_Font_Face
96.99% covered (success)
96.99%
129 / 133
84.62% covered (warning)
84.62%
11 / 13
63
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 wp_loaded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 current_screen
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 print_font_faces
95.52% covered (success)
95.52%
64 / 67
0.00% covered (danger)
0.00%
0 / 1
25
 is_face_provided
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 get_weight_range
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 collect_global_styles_fonts
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 collect_block_fonts
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 add_font
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 format_font
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_font_family_name
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_font_slug_from_setting
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 is_block_editor
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2/**
3 * Jetpack_Google_Font_Face class
4 *
5 * @package automattic/jetpack
6 */
7
8/**
9 * Prints used legacy catalogue faces while native font printers handle theme and user fonts.
10 *
11 * @phan-constructor-used-for-side-effects
12 */
13class Jetpack_Google_Font_Face {
14    /**
15     * The fonts that are used in global styles or block-level settings.
16     *
17     * @var array
18     */
19    private $fonts_in_use = array();
20
21    /**
22     * The constructor.
23     */
24    public function __construct() {
25        // Retire the legacy printer while keeping native font-face printing.
26        add_action( 'wp_loaded', array( $this, 'wp_loaded' ) );
27        add_action( 'current_screen', array( $this, 'current_screen' ), 10 );
28
29        // Collect and print fonts in use
30        if ( wp_is_block_theme() ) {
31            add_action( 'wp_head', array( $this, 'print_font_faces' ), 50 );
32        } else {
33            // In classic themes wp_head runs before the blocks are processed to collect the block fonts.
34            add_action( 'wp_footer', array( $this, 'print_font_faces' ), 50 );
35        }
36        add_filter( 'pre_render_block', array( $this, 'collect_block_fonts' ), 10, 2 );
37    }
38
39    /**
40     * Turn off the legacy webfonts printer on the frontend.
41     */
42    public function wp_loaded() {
43        remove_action( 'wp_head', 'wp_print_fonts', 50 );
44    }
45
46    /**
47     * Turn off hooks to print fonts on wp-admin, except for GB editor pages.
48     */
49    public function current_screen() {
50        remove_action( 'admin_print_styles', 'wp_print_fonts', 50 );
51
52        if ( ! $this->is_block_editor() ) {
53            remove_action( 'admin_print_styles', 'wp_print_font_faces', 50 );
54        }
55    }
56
57    /**
58     * Print fonts that are used in global styles or block-level settings.
59     */
60    public function print_font_faces() {
61        if ( jetpack_google_fonts_load_font_faces() ) {
62            return;
63        }
64
65        $this->collect_global_styles_fonts();
66        $fonts_in_use = array_fill_keys( $this->fonts_in_use, true );
67        if ( empty( $fonts_in_use ) ) {
68            return;
69        }
70
71        $settings     = function_exists( 'gutenberg_get_global_settings' )
72            ? gutenberg_get_global_settings()
73            : wp_get_global_settings();
74        $native_slugs = array();
75        $native_faces = array();
76        foreach ( $settings['typography']['fontFamilies'] ?? array() as $origin => $families ) {
77            foreach ( $families as $family ) {
78                if ( 'default' === $origin && empty( $family['fontFace'] ) ) {
79                    continue;
80                }
81                if ( empty( $family['fontFamily'] ) ) {
82                    continue;
83                }
84                $key = $this->format_font( self::get_font_family_name( $family ) );
85                if ( ! empty( $family['slug'] ) ) {
86                    $native_slugs[ $this->format_font( $family['slug'] ) ] = $key;
87                }
88                if ( ! empty( $family['fontFace'] ) ) {
89                    foreach ( $family['fontFace'] as $face ) {
90                        $native_faces[ $key ][] = $face;
91                    }
92                }
93            }
94        }
95
96        // Presets without their own faces can still refer to a legacy catalogue family.
97        $fonts_in_use = array_fill_keys(
98            array_map(
99                static function ( $font ) use ( $native_slugs ) {
100                    return $native_slugs[ $font ] ?? $font;
101                },
102                array_keys( $fonts_in_use )
103            ),
104            true
105        );
106
107        $data = jetpack_get_google_fonts_data();
108        if ( empty( $data['fontFamilies'] ) ) {
109            return;
110        }
111        $available_fonts = jetpack_get_available_google_fonts_map( $data );
112        $fonts_to_print  = array();
113        foreach ( $data['fontFamilies'] as $family ) {
114            if ( empty( $available_fonts[ $family['name'] ] ) || empty( $family['fontFace'] ) ) {
115                continue;
116            }
117
118            $name = self::get_font_family_name( $family );
119            $key  = $this->format_font( $name );
120            $slug = $this->format_font( $family['slug'] );
121            // A preset that reuses this slug for another family replaces it.
122            if ( isset( $native_slugs[ $slug ] ) && $native_slugs[ $slug ] !== $key ) {
123                continue;
124            }
125            if ( ! isset( $fonts_in_use[ $slug ] ) && ! isset( $fonts_in_use[ $key ] ) ) {
126                continue;
127            }
128
129            $catalogue_faces = $family['fontFace'];
130            if ( isset( $native_faces[ $key ] ) ) {
131                // Native files win; the catalogue only fills styles and weights they lack.
132                $catalogue_faces = array_filter(
133                    $catalogue_faces,
134                    static function ( $face ) use ( $native_faces, $key ) {
135                        return ! self::is_face_provided( $face, $native_faces[ $key ] );
136                    }
137                );
138                if ( empty( $catalogue_faces ) ) {
139                    continue;
140                }
141            }
142
143            $faces = array();
144            foreach ( $catalogue_faces as $face ) {
145                $face['font-family'] = $name;
146                $converted           = array();
147                foreach ( $face as $property => $value ) {
148                    $converted[ _wp_to_kebab_case( $property ) ] = $value;
149                }
150                $faces[] = $converted;
151            }
152            $fonts_to_print[] = $faces;
153        }
154
155        if ( ! empty( $fonts_to_print ) ) {
156            wp_print_font_faces( $fonts_to_print );
157        }
158    }
159
160    /**
161     * Whether a native face already covers a catalogue face's style and weight.
162     *
163     * @param array   $face         Catalogue face.
164     * @param array[] $native_faces Native faces for the same family.
165     * @return bool
166     */
167    private static function is_face_provided( $face, $native_faces ) {
168        $style             = $face['fontStyle'] ?? 'normal';
169        list( $min, $max ) = self::get_weight_range( $face['fontWeight'] ?? '400' );
170        foreach ( $native_faces as $native ) {
171            if ( ( $native['fontStyle'] ?? 'normal' ) !== $style ) {
172                continue;
173            }
174            list( $native_min, $native_max ) = self::get_weight_range( $native['fontWeight'] ?? '400' );
175            // Overlap, not containment: a later catalogue rule would win at a shared weight.
176            if ( $native_min <= $max && $min <= $native_max ) {
177                return true;
178            }
179        }
180        return false;
181    }
182
183    /**
184     * Parses a font-weight descriptor such as "400", "bold" or "100 900".
185     *
186     * @param string|int $weight Font weight.
187     * @return int[] Minimum and maximum weight.
188     */
189    private static function get_weight_range( $weight ) {
190        $weight = str_replace( array( 'normal', 'bold' ), array( '400', '700' ), (string) $weight );
191        $parts  = preg_split( '/\s+/', trim( $weight ) );
192        return array( (int) $parts[0], (int) ( $parts[1] ?? $parts[0] ) );
193    }
194
195    /**
196     * Collect fonts used for global styles settings.
197     */
198    public function collect_global_styles_fonts() {
199        $global_styles = function_exists( 'gutenberg_get_global_styles' )
200            ? gutenberg_get_global_styles()
201            : wp_get_global_styles();
202
203        $global_styles_font_slug = $this->get_font_slug_from_setting( $global_styles );
204        if ( $global_styles_font_slug ) {
205            $this->add_font( $global_styles_font_slug );
206        }
207
208        if ( isset( $global_styles['blocks'] ) ) {
209            foreach ( $global_styles['blocks'] as $setting ) {
210                $font_slug = $this->get_font_slug_from_setting( $setting );
211
212                if ( $font_slug ) {
213                    $this->add_font( $font_slug );
214                }
215            }
216        }
217
218        if ( isset( $global_styles['elements'] ) ) {
219            foreach ( $global_styles['elements'] as $setting ) {
220                $font_slug = $this->get_font_slug_from_setting( $setting );
221
222                if ( $font_slug ) {
223                    $this->add_font( $font_slug );
224                }
225            }
226        }
227    }
228
229    /**
230     * Collect fonts used for block-level settings.
231     *
232     * @filter pre_render_block
233     *
234     * @param string|null $content The pre-rendered content. Default null.
235     * @param array       $parsed_block The block being rendered.
236     */
237    public function collect_block_fonts( $content, $parsed_block ) {
238        if ( ! is_admin() && isset( $parsed_block['attrs']['fontFamily'] ) ) {
239            $block_font_family = $parsed_block['attrs']['fontFamily'];
240            $this->add_font( $block_font_family );
241        }
242
243        return $content;
244    }
245
246    /**
247     * Add the specify font to the fonts_in_use list.
248     *
249     * @param string $font_slug The font slug.
250     */
251    public function add_font( $font_slug ) {
252        if ( is_string( $font_slug ) ) {
253            $this->fonts_in_use[] = $this->format_font( $font_slug );
254        }
255    }
256
257    /**
258     * Format the given font slug.
259     *
260     * @example "ABeeZee" formats to "abeezee"
261     * @example "ADLaM Display" formats to "adlam-display"
262     * @param string $font_slug The font slug.
263     * @return string The formatted font slug.
264     */
265    public function format_font( $font_slug ) {
266        return _wp_to_kebab_case( strtolower( $font_slug ) );
267    }
268
269    /**
270     * Get the font family name from a font.
271     *
272     * @param array $font The font definition object.
273     */
274    public static function get_font_family_name( $font ) {
275        $font_family = $font['fontFamily'];
276        if ( str_contains( $font_family, ',' ) ) {
277            $font_family = explode( ',', $font_family )[0];
278        }
279
280        return trim( $font_family, "\"'" );
281    }
282
283    /**
284     * Get the font family slug from a settings array.
285     *
286     * @param array $setting The settings object.
287     *
288     * @return string|null
289     */
290    public function get_font_slug_from_setting( $setting ) {
291        if ( ! isset( $setting['typography']['fontFamily'] ) ) {
292            return null;
293        }
294
295        $font_family = $setting['typography']['fontFamily'];
296
297        // The font family may be a reference to a path to the value stored at that location,
298        // e.g.: { "ref": "styles.elements.heading.typography.fontFamily" }.
299        // Ignore it as we also get the value stored at that location from the setting.
300        if ( ! is_string( $font_family ) ) {
301            return null;
302        }
303
304        // A preset reference can include a var() fallback and a trailing font stack.
305        preg_match( '/^\s*var\(\s*--wp--preset--font-family--(?P<slug>[^\s,()]+)\s*[,)]/', $font_family, $matches );
306
307        if ( isset( $matches['slug'] ) ) {
308            return $matches['slug'];
309        }
310
311        // Full string: var:preset|font-family|slug
312        // We do not care about the origin of the font, only its slug.
313        preg_match( '/^\s*var:preset\|font-family\|(?P<slug>[^\s,]+)/', $font_family, $matches );
314
315        if ( isset( $matches['slug'] ) ) {
316            return $matches['slug'];
317        }
318
319        return self::get_font_family_name( $setting['typography'] );
320    }
321
322    /**
323     * Check if the current screen is the block editor.
324     *
325     * @return bool
326     */
327    public function is_block_editor() {
328        if ( function_exists( 'get_current_screen' ) ) {
329            $current_screen = get_current_screen();
330            if ( ! empty( $current_screen ) && method_exists( $current_screen, 'is_block_editor' ) && $current_screen->is_block_editor() ) {
331                return true;
332            }
333        }
334
335        return false;
336    }
337}