PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / 2.0.1
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant v2.0.1
2.0.4 2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Frontend / MetricFallbacks.php

MetricFallbacks.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant 2.0.1, at src/Frontend/MetricFallbacks.php

170 lines 4.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metric-matched fallback fonts (zero-CLS).
4 *
5 * For every hosted family with extracted metrics, generates a fallback
6 * @font-face that aliases a local system font but overrides its metrics
7 * (size-adjust, ascent/descent/line-gap-override) so it occupies the same space
8 * as the web font — eliminating layout shift during swap.
9 *
10 * @package EasyFonts
11 */
12
13 namespace EasyFonts\Frontend;
14
15 use EasyFonts\Fonts\Registry;
16 use EasyFonts\Parser\FontMetricsReader;
17
18 defined( 'ABSPATH' ) || exit;
19
20 /**
21 * Builds + injects metric-matched fallback faces.
22 */
23 class MetricFallbacks {
24
25 /**
26 * @var Registry
27 */
28 private Registry $registry;
29
30 /**
31 * Constructor.
32 */
33 public function __construct() {
34 $this->registry = new Registry();
35 }
36
37 /**
38 * Build the fallback stylesheet for all families touched this request.
39 *
40 * @param array<int,array{family:string,weight:string,style:string}> $touched Touched fonts.
41 * @return string CSS (may be empty).
42 */
43 public function build_for( array $touched ): string {
44 $seen = array();
45 $css = '';
46
47 foreach ( $touched as $item ) {
48 $family = $item['family'];
49 $key = strtolower( $family );
50
51 if ( isset( $seen[ $key ] ) ) {
52 continue;
53 }
54
55 $seen[ $key ] = true;
56
57 $metrics = $this->registry->metrics_for( $family );
58
59 if ( ! $metrics ) {
60 continue;
61 }
62
63 $face = $this->build_face( $family, $metrics );
64
65 if ( '' !== $face ) {
66 $css .= $face;
67 }
68 }
69
70 return $css;
71 }
72
73 /**
74 * Compute the override values and emit a fallback @font-face + nothing else.
75 * The fallback family is named "<Family> Fallback"; we also append it to the
76 * original family via a small @font-face alias is not possible, so callers
77 * should rely on the generated stack. Here we emit the override face only.
78 *
79 * @param string $family Family.
80 * @param array<string,float|int> $metrics Web-font metrics.
81 * @return string
82 */
83 private function build_face( string $family, array $metrics ): string {
84 $units = (float) ( $metrics['unitsPerEm'] ?? 0 );
85
86 if ( $units <= 0 ) {
87 return '';
88 }
89
90 $ascent = (float) ( $metrics['ascent'] ?? 0 );
91 $descent = (float) ( $metrics['descent'] ?? 0 );
92 $gap = (float) ( $metrics['lineGap'] ?? 0 );
93 $x_avg = (float) ( $metrics['xAvgCharWidth'] ?? 0 );
94
95 $category = $this->guess_category( $family );
96 $fallback = FontMetricsReader::system_fallbacks()[ $category ];
97
98 // size-adjust = webfont avg-width ratio / fallback avg-width ratio.
99 $web_ratio = $x_avg > 0 ? ( $x_avg / $units ) : 0;
100 $size_adj = ( $web_ratio > 0 && $fallback['ratio'] > 0 ) ? ( $web_ratio / $fallback['ratio'] ) : 1.0;
101
102 if ( $size_adj <= 0 ) {
103 $size_adj = 1.0;
104 }
105
106 // Overrides are relative to the web font, scaled by size-adjust.
107 $ascent_pct = ( $ascent / ( $units * $size_adj ) ) * 100;
108 $descent_pct = ( abs( $descent ) / ( $units * $size_adj ) ) * 100;
109 $gap_pct = ( $gap / ( $units * $size_adj ) ) * 100;
110
111 // Harden against CSS-context injection: a malicious @font-face family
112 // name elsewhere on the site must not be able to break out of the
113 // declaration. Strip quotes/braces/semicolons before interpolation.
114 $family_css = self::css_safe( $family );
115 $fallback_css = self::css_safe( (string) $fallback['local'] );
116
117 return sprintf(
118 "@font-face{font-family:'%s Fallback';src:local('%s');size-adjust:%s%%;ascent-override:%s%%;descent-override:%s%%;line-gap-override:%s%%;}\n",
119 $family_css,
120 $fallback_css,
121 $this->fmt( $size_adj * 100 ),
122 $this->fmt( $ascent_pct ),
123 $this->fmt( $descent_pct ),
124 $this->fmt( $gap_pct )
125 );
126 }
127
128 /**
129 * Strip characters that could break out of a CSS string/declaration.
130 *
131 * @param string $text Raw text.
132 * @return string
133 */
134 private static function css_safe( string $text ): string {
135 return trim( str_replace( array( "'", '"', ';', '{', '}', '<', '>', "\\", "\r", "\n" ), '', $text ) );
136 }
137
138 /**
139 * Naive serif/sans/mono category guess from the family name.
140 *
141 * @param string $family Family.
142 * @return string
143 */
144 private function guess_category( string $family ): string {
145 $lower = strtolower( $family );
146
147 if ( false !== strpos( $lower, 'mono' ) || false !== strpos( $lower, 'code' ) || false !== strpos( $lower, 'courier' ) ) {
148 return 'monospace';
149 }
150
151 foreach ( array( 'serif', 'slab', 'georgia', 'times', 'playfair', 'merriweather', 'lora', 'pt serif', 'noto serif' ) as $hint ) {
152 if ( false !== strpos( $lower, $hint ) ) {
153 return 'serif';
154 }
155 }
156
157 return 'sans-serif';
158 }
159
160 /**
161 * Format a percentage with up to 4 decimals, trimmed.
162 *
163 * @param float $value Value.
164 * @return string
165 */
166 private function fmt( float $value ): string {
167 return rtrim( rtrim( number_format( $value, 4, '.', '' ), '0' ), '.' );
168 }
169 }
170