PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / trunk
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder vtrunk
2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.2.0 1.2.1 All 78 releases
ablocks / includes / classes / font-stack.php

font-stack.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder trunk, at includes/classes/font-stack.php

429 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocks\Classes;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 /**
9 * Builds every `font-family` value aBlocks emits.
10 *
11 * A picker stores a bare family name ("Roboto") because that name is also the
12 * key used to download/self-host the font (see FontCollector + FontLoadLocally).
13 * The bare name must never reach CSS on its own: if the web font is slow, blocked
14 * or missing, the browser falls back to its default serif and the design breaks.
15 *
16 * So the stack is assembled at emit time instead:
17 *
18 * "Roboto", "Roboto Fallback", sans-serif
19 * | | |
20 * | | generic, from the family's category
21 * | metric-adjusted local face - same footprint as the web font,
22 * | so swapping in the real font shifts nothing (CLS)
23 * properly quoted family name
24 *
25 * @package ABlocks
26 */
27 class FontStack {
28
29 /**
30 * Suffix for the generated metric-adjusted fallback face.
31 */
32 const FALLBACK_SUFFIX = ' Fallback';
33
34 /**
35 * family => generic category. Loaded once per request.
36 *
37 * @var array|null
38 */
39 protected static $categories = null;
40
41 /**
42 * family => 'local face|size-adjust|ascent|descent|line-gap'.
43 *
44 * Stored pipe-joined rather than as nested arrays: 1,950 nested arrays cost
45 * ~2.5x more to parse on every request, and only the two or three families a
46 * page actually uses ever need splitting.
47 *
48 * @var array|null
49 */
50 protected static $metrics = null;
51
52 /**
53 * Memoised family => category lookups, so the case-insensitive fallback scan
54 * in get_category() runs at most once per family per request.
55 *
56 * @var array
57 */
58 protected static $category_cache = [];
59
60 /**
61 * Generic CSS families a fallback override may use verbatim.
62 *
63 * @var string[]
64 */
65 const GENERIC_FAMILIES = [
66 'serif',
67 'sans-serif',
68 'monospace',
69 'cursive',
70 'fantasy',
71 'system-ui',
72 'ui-serif',
73 'ui-sans-serif',
74 'ui-monospace',
75 'ui-rounded',
76 'math',
77 'emoji',
78 'fangsong',
79 ];
80
81 /**
82 * Google's font categories mapped onto CSS generic families.
83 *
84 * @var array
85 */
86 const CATEGORY_TO_GENERIC = [
87 'sans-serif' => 'sans-serif',
88 'serif' => 'serif',
89 'monospace' => 'monospace',
90 'display' => 'sans-serif',
91 'handwriting' => 'cursive',
92 ];
93
94 /**
95 * Category lookup table.
96 *
97 * @return array
98 */
99 public static function categories() {
100 if ( null === self::$categories ) {
101 $file = ABLOCKS_BLOCKS_DIR_PATH . 'fonts.php';
102 self::$categories = is_readable( $file ) ? (array) include $file : [];
103 }
104 return self::$categories;
105 }
106
107 /**
108 * Metric table for the generated fallback faces.
109 *
110 * @return array
111 */
112 public static function metrics() {
113 if ( null === self::$metrics ) {
114 $file = ABLOCKS_BLOCKS_DIR_PATH . 'font-metrics.php';
115 self::$metrics = is_readable( $file ) ? (array) include $file : [];
116 }
117 return self::$metrics;
118 }
119
120 /**
121 * A family's category ('serif', 'display', …), or '' when unknown.
122 *
123 * @param string $family Font family name.
124 * @return string
125 */
126 public static function get_category( $family ) {
127 if ( isset( self::$category_cache[ $family ] ) ) {
128 return self::$category_cache[ $family ];
129 }
130
131 $categories = self::categories();
132 $category = isset( $categories[ $family ] ) ? $categories[ $family ] : '';
133
134 if ( '' === $category ) {
135 // Tolerate case drift between a saved value and the catalog. This walks
136 // ~2,000 entries, so the result is memoised — build() is called once per
137 // font-family declaration and a page can have dozens.
138 foreach ( $categories as $name => $value ) {
139 if ( 0 === strcasecmp( $name, $family ) ) {
140 $category = $value;
141 break;
142 }
143 }
144 }
145
146 $category = (string) apply_filters( 'ablocks/font_category', $category, $family );
147
148 self::$category_cache[ $family ] = $category;
149
150 return $category;
151 }
152
153 /**
154 * Whether metric-adjusted fallback faces are switched on.
155 *
156 * @return bool
157 */
158 public static function metric_fallback_enabled() {
159 return (bool) apply_filters(
160 'ablocks/font_metric_fallback_enabled',
161 \ABlocks\Helper::get_settings( 'font_metric_fallback', true )
162 );
163 }
164
165 /**
166 * Name of the generated metric-adjusted face for a family.
167 *
168 * @param string $family Font family name.
169 * @return string
170 */
171 public static function fallback_face_name( $family ) {
172 return $family . self::FALLBACK_SUFFIX;
173 }
174
175 /**
176 * Whether a family has metrics to build a fallback face from.
177 *
178 * @param string $family Font family name.
179 * @return bool
180 */
181 public static function has_metrics( $family ) {
182 $metrics = self::metrics();
183 return isset( $metrics[ $family ] );
184 }
185
186 /**
187 * Quote a family name when CSS requires it.
188 *
189 * Unquoted family names must be a sequence of CSS identifiers, so anything
190 * with a digit-leading word ("42dot Sans") or punctuation has to be quoted.
191 * Quoting on any whitespace as well keeps the output unambiguous.
192 *
193 * @param string $family Font family name.
194 * @return string
195 */
196 public static function quote( $family ) {
197 $family = trim( (string) $family );
198 if ( '' === $family ) {
199 return '';
200 }
201
202 $needs_quotes = (bool) preg_match( '/[^a-zA-Z0-9_-]/', $family )
203 || (bool) preg_match( '/(^|\s)[0-9-]/', $family );
204
205 if ( ! $needs_quotes ) {
206 return $family;
207 }
208
209 return '"' . str_replace( [ '\\', '"' ], [ '\\\\', '\"' ], $family ) . '"';
210 }
211
212 /**
213 * Resolve the generic (or custom) tail of the stack.
214 *
215 * @param string $family Font family name.
216 * @param string $fallback Author-supplied override: a generic keyword, a full
217 * custom stack, or '' to derive it from the category.
218 * @return string
219 */
220 public static function resolve_fallback( $family, $fallback = '' ) {
221 $fallback = trim( (string) $fallback );
222
223 if ( '' !== $fallback ) {
224 // A generic keyword, or a hand-written stack the author owns.
225 if ( in_array( strtolower( $fallback ), self::GENERIC_FAMILIES, true ) ) {
226 return strtolower( $fallback );
227 }
228 if ( 'none' === strtolower( $fallback ) ) {
229 return '';
230 }
231 return $fallback;
232 }
233
234 $category = self::get_category( $family );
235
236 if ( isset( self::CATEGORY_TO_GENERIC[ $category ] ) ) {
237 $generic = self::CATEGORY_TO_GENERIC[ $category ];
238 } else {
239 // Unknown family (an uploaded or theme font). Fall back to the site-wide
240 // default from Global Settings → Typography.
241 $generic = trim( (string) \ABlocks\Helper::get_settings( 'global_font_family_fallback', 'sans-serif' ) );
242 if ( '' === $generic ) {
243 $generic = 'sans-serif';
244 }
245 if ( in_array( strtolower( $generic ), self::GENERIC_FAMILIES, true ) ) {
246 $generic = strtolower( $generic );
247 }
248 }
249
250 return (string) apply_filters( 'ablocks/font_generic_fallback', $generic, $family, $category );
251 }
252
253 /**
254 * Build the full font-family value for a stored family name.
255 *
256 * @param string $family Family name as stored on the block attribute.
257 * @param string $fallback Optional author override for the generic tail.
258 * @return string Complete CSS value, or '' when nothing should be emitted.
259 */
260 public static function build( $family, $fallback = '' ) {
261 $family = trim( (string) $family );
262
263 // "Default" means "don't set a font" - emitting it produced the invalid
264 // declaration `font-family: Default`. Declaring nothing is also how the
265 // theme's font wins: font-family inherits, so the theme's rules cascade
266 // untouched. That is why the picker has no separate "inherit" entry.
267 if ( '' === $family || 'Default' === $family ) {
268 return '';
269 }
270
271 // Not offered in the picker (it takes the *parent's* font, which overrides
272 // a theme's own h2/button rules), but honoured if set via a filter or left
273 // on content from an earlier build.
274 if ( 'inherit' === strtolower( $family ) ) {
275 return 'inherit';
276 }
277
278 // Already a stack (legacy content, or a custom value typed by hand), or a
279 // functional value such as var(--ablocks-heading-font-family). Either way
280 // it is already a complete declaration - never quote or append to it.
281 if ( false !== strpos( $family, ',' ) || false !== strpos( $family, '(' ) ) {
282 return $family;
283 }
284
285 $stack = [ self::quote( $family ) ];
286
287 if ( self::metric_fallback_enabled() && self::has_metrics( $family ) ) {
288 $stack[] = self::quote( self::fallback_face_name( $family ) );
289 }
290
291 $generic = self::resolve_fallback( $family, $fallback );
292 if ( '' !== $generic ) {
293 $stack[] = $generic;
294 }
295
296 return (string) apply_filters(
297 'ablocks/font_stack',
298 implode( ', ', $stack ),
299 $family,
300 $fallback
301 );
302 }
303
304 /**
305 * @font-face rules for the metric-adjusted fallback faces of the given families.
306 *
307 * The face is a local() system font re-scaled to the web font's own metrics,
308 * so a line of text occupies the same box before and after the web font
309 * arrives. Nothing is downloaded - `local()` only ever matches installed fonts.
310 *
311 * @param array $families List of family names (or a family => weights map).
312 * @return string CSS, empty when there is nothing to emit.
313 */
314 public static function get_fallback_face_css( $families ) {
315 if ( empty( $families ) || ! self::metric_fallback_enabled() ) {
316 return '';
317 }
318
319 // Accept both [ 'Roboto' => ['400'] ] and [ 'Roboto' ].
320 $names = isset( $families[0] )
321 ? array_map( 'strval', array_values( $families ) )
322 : array_map( 'strval', array_keys( $families ) );
323
324 $metrics = self::metrics();
325 $css = '';
326
327 foreach ( array_unique( $names ) as $family ) {
328 if ( ! isset( $metrics[ $family ] ) ) {
329 continue;
330 }
331
332 $face = explode( '|', $metrics[ $family ] );
333 if ( 5 !== count( $face ) ) {
334 continue;
335 }
336 list( $local, $size_adjust, $ascent, $descent, $line_gap ) = $face;
337
338 $css .= sprintf(
339 '@font-face{font-family:%s;src:local("%s"),local("%s");size-adjust:%s;ascent-override:%s;descent-override:%s;line-gap-override:%s;font-display:swap;}',
340 self::quote( self::fallback_face_name( $family ) ),
341 $local,
342 self::local_alias( $local ),
343 $size_adjust,
344 $ascent,
345 $descent,
346 $line_gap
347 );
348 }
349
350 return $css;
351 }
352
353 /**
354 * Font families WordPress itself already knows about: the theme's own
355 * theme.json fonts plus anything installed/activated through the Font Library
356 * (WP 6.5+). Core prints their @font-face rules, so aBlocks only has to offer
357 * them in the picker — nothing to download or self-host.
358 *
359 * @return array List of [ 'label' => 'Inter', 'value' => '"Inter", sans-serif', 'source' => 'theme' ].
360 */
361 public static function get_theme_font_families() {
362 static $cache = null;
363 if ( null !== $cache ) {
364 return $cache;
365 }
366
367 $cache = [];
368
369 if ( ! class_exists( '\WP_Theme_JSON_Resolver' ) ) {
370 return $cache;
371 }
372
373 $settings = \WP_Theme_JSON_Resolver::get_merged_data()->get_settings();
374 $families = isset( $settings['typography']['fontFamilies'] )
375 ? $settings['typography']['fontFamilies']
376 : [];
377
378 // WP 6.6+ keys presets by origin ( theme / custom / default ); older
379 // versions hand back a flat list.
380 if ( isset( $families[0] ) ) {
381 $families = [ 'theme' => $families ];
382 }
383
384 $seen = [];
385 foreach ( (array) $families as $origin => $presets ) {
386 foreach ( (array) $presets as $preset ) {
387 if ( empty( $preset['fontFamily'] ) ) {
388 continue;
389 }
390 $value = $preset['fontFamily'];
391 if ( isset( $seen[ $value ] ) ) {
392 continue;
393 }
394 $seen[ $value ] = true;
395
396 $label = ! empty( $preset['name'] ) ? $preset['name'] : $value;
397
398 $cache[] = [
399 'label' => $label,
400 'value' => $value,
401 'source' => (string) $origin,
402 ];
403 }
404 }
405
406 return (array) apply_filters( 'ablocks/theme_font_families', $cache );
407 }
408
409 /**
410 * Second local() candidate, so the face still resolves where the primary
411 * donor font is not installed (Arial is absent on most Linux boxes).
412 *
413 * @param string $local Primary local face name.
414 * @return string
415 */
416 protected static function local_alias( $local ) {
417 switch ( $local ) {
418 case 'Arial':
419 return 'Helvetica Neue';
420 case 'Times New Roman':
421 return 'Liberation Serif';
422 case 'Courier New':
423 return 'Liberation Mono';
424 default:
425 return $local;
426 }
427 }
428 }
429