Changeset 3715895 for optimazz/trunk/includes/Modules/Fonts/FontsModule.php
- Timestamp:
- 09/27/2026 05:57:08 PM (4 weeks ago)
- File:
-
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
optimazz/trunk/includes/Modules/Fonts/FontsModule.php
r3657524 r3715895 16 16 17 17 use Optimazz\Core\AbstractModule; 18 use Optimazz\Core\Paths; 18 19 19 20 defined( 'ABSPATH' ) || exit; … … 64 65 65 66 $uses_google = false !== stripos( $html, 'fonts.googleapis.com' ); 67 68 // Merge every Google Fonts stylesheet into ONE request and drop the font 69 // weights the page never uses (page builders like Elementor request every 70 // weight 100–900 plus italics for each family — often 150KB+ of font CSS 71 // and a render-blocking request per family). 72 if ( $uses_google && $this->option( 'combine_google', true ) ) { 73 $html = $this->optimize_google_fonts( $html, $display ); 74 } 66 75 67 76 // Force the chosen font-display on Google Fonts stylesheet URLs. We … … 130 139 } 131 140 141 /* --------------------------------------------------------------------- * 142 * Google Fonts: combine + trim unused weights 143 * --------------------------------------------------------------------- */ 144 145 /** 146 * Replace all Google Fonts stylesheet <link>s with a single CSS2 API request 147 * containing only the families/weights actually needed. Anything we can't 148 * parse with certainty (e.g. `text=` subsetting) is left untouched, and the 149 * whole step fails open — the page is never left without its fonts. 150 * 151 * @param string $html Page HTML. 152 * @param string $display font-display value to request ('' keeps original). 153 * @return string 154 */ 155 private function optimize_google_fonts( $html, $display ) { 156 if ( ! preg_match_all( '#<link\b[^>]*>#i', $html, $all ) ) { 157 return $html; 158 } 159 160 $links = array(); // Tags we will merge (in document order). 161 $families = array(); // name => ['normal'=>[w=>1], 'italic'=>[w=>1], 'raw'=>string|null] 162 $orig_display = ''; 163 164 foreach ( $all[0] as $tag ) { 165 if ( ! preg_match( '#\brel\s*=\s*["\']?stylesheet\b#i', $tag ) ) { 166 continue; 167 } 168 if ( ! preg_match( '#\bhref\s*=\s*(["\'])((?:https?:)?//fonts\.googleapis\.com/(css2?)\?[^"\']+)\1#i', $tag, $m ) ) { 169 continue; 170 } 171 if ( preg_match( '#\bmedia\s*=\s*["\'](?!all\b|screen\b)[^"\']+#i', $tag ) ) { 172 continue; // print-only etc. — leave alone. 173 } 174 $query = (string) wp_parse_url( html_entity_decode( $m[2], ENT_QUOTES ), PHP_URL_QUERY ); // decode first: "&" contains a "#" that would truncate the query. 175 $parsed = 'css2' === strtolower( $m[3] ) ? self::parse_css2( $query ) : self::parse_css1( $query ); 176 if ( null === $parsed ) { 177 continue; // unsupported form — keep as-is. 178 } 179 if ( '' === $orig_display && preg_match( '/(?:^|&)display=([a-z]+)/i', $query, $d ) ) { 180 $orig_display = strtolower( $d[1] ); 181 } 182 foreach ( $parsed as $name => $spec ) { 183 if ( ! isset( $families[ $name ] ) ) { 184 $families[ $name ] = array( 'normal' => array(), 'italic' => array(), 'raw' => null ); 185 } 186 if ( null !== $spec['raw'] ) { 187 $families[ $name ]['raw'] = $spec['raw']; 188 } 189 $families[ $name ]['normal'] += $spec['normal']; 190 $families[ $name ]['italic'] += $spec['italic']; 191 } 192 $links[] = $tag; 193 } 194 195 if ( empty( $links ) ) { 196 return $html; 197 } 198 $trim = (bool) $this->option( 'trim_weights', true ); 199 // One link and nothing to trim — nothing to gain. 200 if ( 1 === count( $links ) && ! $trim ) { 201 return $html; 202 } 203 204 $used = $trim ? $this->used_font_weights( $html ) : null; 205 206 $params = array(); 207 foreach ( $families as $name => $spec ) { 208 $params[] = 'family=' . str_replace( '%20', '+', rawurlencode( $name ) ) . self::css2_axis( $spec, $used ); 209 } 210 $disp = '' !== $display ? $display : $orig_display; 211 if ( '' !== $disp ) { 212 $params[] = 'display=' . $disp; 213 } 214 $url = 'https://fonts.googleapis.com/css2?' . implode( '&', $params ); 215 // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- rewriting existing output-buffer markup, not enqueueing. 216 $new = '<link rel="stylesheet" id="optimazz-google-fonts" href="' . esc_url( $url ) . '" media="all">'; 217 218 // Swap the first link for the merged one; remove the rest (each once). 219 $first = true; 220 foreach ( $links as $tag ) { 221 $pos = strpos( $html, $tag ); 222 if ( false === $pos ) { 223 continue; 224 } 225 $html = substr_replace( $html, $first ? $new : '', $pos, strlen( $tag ) ); 226 $first = false; 227 } 228 return $html; 229 } 230 231 /** 232 * Parse a legacy CSS API query (`family=Inter:400,700i|Roboto&subset=…`). 233 * 234 * @param string $query URL query string. 235 * @return array|null Families, or null when the form is unsupported. 236 */ 237 private static function parse_css1( $query ) { 238 $out = array(); 239 foreach ( explode( '&', $query ) as $pair ) { 240 $kv = explode( '=', $pair, 2 ); 241 $key = strtolower( $kv[0] ); 242 if ( 'text' === $key ) { 243 return null; // subset-by-text requests must stay exact. 244 } 245 if ( 'family' !== $key || ! isset( $kv[1] ) ) { 246 continue; 247 } 248 foreach ( explode( '|', urldecode( $kv[1] ) ) as $fam ) { 249 $parts = explode( ':', $fam, 2 ); 250 $name = trim( $parts[0] ); 251 if ( '' === $name ) { 252 continue; 253 } 254 $spec = array( 'normal' => array(), 'italic' => array(), 'raw' => null ); 255 $vars = isset( $parts[1] ) ? explode( ',', $parts[1] ) : array( '400' ); 256 foreach ( $vars as $v ) { 257 $v = strtolower( trim( $v ) ); 258 if ( '' === $v ) { 259 continue; 260 } 261 $v = strtr( $v, array( 'regular' => '400', 'bold' => '700' ) ); 262 $ital = (bool) preg_match( '/(italic|i)$/', $v ); 263 $w = (int) preg_replace( '/\D/', '', $v ); 264 $w = $w ? $w : 400; // bare "italic". 265 if ( $w < 100 || $w > 900 ) { 266 continue; 267 } 268 $spec[ $ital ? 'italic' : 'normal' ][ $w ] = 1; 269 } 270 if ( empty( $spec['normal'] ) && empty( $spec['italic'] ) ) { 271 $spec['normal'][400] = 1; 272 } 273 $out[ $name ] = $spec; 274 } 275 } 276 return $out ? $out : null; 277 } 278 279 /** 280 * Parse a CSS2 API query (`family=Inter:ital,wght@0,400;1,700&family=…`). 281 * 282 * @param string $query URL query string. 283 * @return array|null Families, or null when the form is unsupported. 284 */ 285 private static function parse_css2( $query ) { 286 $out = array(); 287 foreach ( explode( '&', $query ) as $pair ) { 288 $kv = explode( '=', $pair, 2 ); 289 $key = strtolower( $kv[0] ); 290 if ( 'text' === $key ) { 291 return null; 292 } 293 if ( 'family' !== $key || ! isset( $kv[1] ) ) { 294 continue; 295 } 296 $fam = urldecode( $kv[1] ); 297 $parts = explode( ':', $fam, 2 ); 298 $name = trim( $parts[0] ); 299 if ( '' === $name ) { 300 continue; 301 } 302 $spec = array( 'normal' => array(), 'italic' => array(), 'raw' => null ); 303 if ( ! isset( $parts[1] ) ) { 304 $spec['normal'][400] = 1; 305 } elseif ( preg_match( '/^ital,wght@(.+)$/', $parts[1], $m ) && false === strpos( $m[1], '..' ) ) { 306 foreach ( explode( ';', $m[1] ) as $t ) { 307 $b = explode( ',', $t ); 308 if ( 2 === count( $b ) ) { 309 $spec[ '1' === trim( $b[0] ) ? 'italic' : 'normal' ][ (int) $b[1] ] = 1; 310 } 311 } 312 } elseif ( preg_match( '/^wght@(.+)$/', $parts[1], $m ) && false === strpos( $m[1], '..' ) ) { 313 foreach ( explode( ';', $m[1] ) as $w ) { 314 $spec['normal'][ (int) $w ] = 1; 315 } 316 } else { 317 $spec['raw'] = $parts[1]; // variable ranges / other axes — keep verbatim. 318 } 319 unset( $spec['normal'][0], $spec['italic'][0] ); 320 $out[ $name ] = $spec; 321 } 322 return $out ? $out : null; 323 } 324 325 /** 326 * Build the CSS2 axis suffix for one family, trimmed to the used weights. 327 * 328 * @param array $spec Family spec. 329 * @param array|null $used ['weights'=>[w=>1], 'italic'=>bool] or null (no trim). 330 * @return string e.g. ":ital,wght@0,400;0,700;1,400" or ":wght@400;700" or "". 331 */ 332 private static function css2_axis( array $spec, $used ) { 333 if ( null !== $spec['raw'] ) { 334 return ':' . $spec['raw']; 335 } 336 $normal = $spec['normal']; 337 $italic = $spec['italic']; 338 if ( is_array( $used ) ) { 339 $n = array_intersect_key( $normal, $used['weights'] ); 340 $i = $used['italic'] ? array_intersect_key( $italic, $used['weights'] ) : array(); 341 // Never trim a family down to nothing — keep the original request. 342 if ( ! empty( $n ) || ! empty( $i ) ) { 343 $normal = $n; 344 $italic = $i; 345 } 346 } 347 ksort( $normal ); 348 ksort( $italic ); 349 if ( empty( $italic ) ) { 350 if ( array( 400 ) === array_keys( $normal ) ) { 351 return ''; 352 } 353 return ':wght@' . implode( ';', array_keys( $normal ) ); 354 } 355 $tuples = array(); 356 foreach ( array_keys( $normal ) as $w ) { 357 $tuples[] = '0,' . $w; 358 } 359 foreach ( array_keys( $italic ) as $w ) { 360 $tuples[] = '1,' . $w; 361 } 362 return ':ital,wght@' . implode( ';', $tuples ); 363 } 364 365 /** 366 * Work out which font weights (and whether italics) the page can actually 367 * render, from the page's own markup/inline CSS plus its local stylesheets. 368 * Deliberately conservative: 400 and 700 are always kept (default text and 369 * <strong>/<b>/headings), and any weight mentioned anywhere is kept. 370 * 371 * @param string $html Page HTML. 372 * @return array ['weights'=>[w=>1], 'italic'=>bool] 373 */ 374 private function used_font_weights( $html ) { 375 $weights = array( 400 => 1, 700 => 1 ); 376 $italic = (bool) preg_match( '#<(em|cite|dfn|var|address)\b|<i>#i', $html ); 377 378 self::scan_weights( $html, $weights, $italic ); 379 380 // Local stylesheets: scanned once per set of files (cached until any of 381 // them changes), so this adds nothing to normal page views. 382 $files = array(); 383 if ( preg_match_all( '#<link\b[^>]*\brel\s*=\s*["\']?stylesheet[^>]*>#i', $html, $lm ) ) { 384 foreach ( $lm[0] as $tag ) { 385 if ( ! preg_match( '#\bhref\s*=\s*["\']([^"\']+)#i', $tag, $h ) ) { 386 continue; 387 } 388 $path = Paths::url_to_path( html_entity_decode( $h[1], ENT_QUOTES ) ); 389 if ( $path && is_readable( $path ) && filesize( $path ) < 2097152 ) { 390 $files[ $path ] = (int) filemtime( $path ); 391 } 392 if ( count( $files ) >= 80 ) { 393 break; 394 } 395 } 396 } 397 if ( $files ) { 398 $key = 'optimazz_fw_' . md5( wp_json_encode( $files ) ); 399 $cached = get_transient( $key ); 400 if ( is_array( $cached ) && isset( $cached['w'] ) ) { 401 $weights += $cached['w']; 402 $italic = $italic || ! empty( $cached['i'] ); 403 } else { 404 $fw = array(); 405 $fi = false; 406 foreach ( array_keys( $files ) as $path ) { 407 self::scan_weights( (string) @file_get_contents( $path ), $fw, $fi ); // phpcs:ignore WordPress.WP.AlternativeFunctions,WordPress.PHP.NoSilencedErrors 408 } 409 set_transient( $key, array( 'w' => $fw, 'i' => $fi ), DAY_IN_SECONDS ); 410 $weights += $fw; 411 $italic = $italic || $fi; 412 } 413 } 414 415 return array( 416 'weights' => $weights, 417 'italic' => $italic, 418 ); 419 } 420 421 /** 422 * Collect font-weight values and italic usage from CSS/markup text. 423 * 424 * @param string $text CSS or HTML. 425 * @param array $weights Weight set (by reference). 426 * @param bool $italic Italic flag (by reference). 427 * @return void 428 */ 429 private static function scan_weights( $text, array &$weights, &$italic ) { 430 if ( preg_match_all( '/font-weight\s*:\s*([1-9]00|bold|normal)\b/i', $text, $m ) ) { 431 foreach ( $m[1] as $v ) { 432 $v = strtolower( $v ); 433 $w = 'bold' === $v ? 700 : ( 'normal' === $v ? 400 : (int) $v ); 434 $weights[ $w ] = 1; 435 } 436 } 437 // Shorthand, e.g. "font: italic 600 1rem/1.4 Inter". 438 if ( preg_match_all( '/\bfont\s*:\s*[^;}{]*?\b([1-9]00)\b/i', $text, $m ) ) { 439 foreach ( $m[1] as $v ) { 440 $weights[ (int) $v ] = 1; 441 } 442 } 443 if ( ! $italic && preg_match( '/font-style\s*:\s*italic|\bfont\s*:[^;}{]*\bitalic\b/i', $text ) ) { 444 $italic = true; 445 } 446 } 447 132 448 /** 133 449 * Guess the MIME type for a font URL.
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)