Skip to content

Commit e923036

Browse files
authored
Merge pull request Automattic#839 from faisalahammad/fix/732-font-cap-preserve-stylesheet
fix(fonts): preserve source stylesheet on Google Fonts cap overflow
2 parents 9054a28 + 829143d commit e923036

4 files changed

Lines changed: 393 additions & 33 deletions

File tree

homeboy-test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"tests/smoke-validation-runtime-diagnostics.php": { "environment": "standalone-php" },
3434
"tests/smoke-visual-repair-css.php": { "environment": "standalone-php" },
3535
"tests/smoke-webfont-producer-consumer.php": { "environment": "standalone-php" },
36+
"tests/smoke-google-fonts-cap-fallback.php": { "environment": "standalone-php" },
3637
"tests/smoke-website-artifact-import-input.php": { "environment": "standalone-php" },
3738
"tests/smoke-wordpress-site-plan-materializer.php": { "environment": "standalone-php" },
3839
"tests/smoke-artifact-run-primitives.php": { "environment": "standalone-php" },

includes/class-static-site-importer-font-materializer.php

Lines changed: 187 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,59 @@ public static function prepare_overlay( array $plan, array $resolved_plan ) {
7878
}
7979

8080
$font_faces = self::resolve_google_font_faces( $plan, $families, $diagnostics );
81-
if ( '' === $font_faces ) {
81+
if ( 'preserved' === $font_faces['state'] ) {
82+
$preserved_url = (string) $font_faces['url'];
83+
$fallback_url = self::is_google_stylesheet_url( $preserved_url ) ? $preserved_url : '';
84+
$imports = array();
85+
foreach ( $plan['stylesheets'] ?? array() as $stylesheet ) {
86+
$content = is_array( $stylesheet ) && is_scalar( $stylesheet['content'] ?? null ) ? (string) $stylesheet['content'] : '';
87+
if ( preg_match_all( '/@import\s+(?:url\()?\s*["\']?([^"\'\s\)]+)["\']?\s*\)?/i', $content, $matches ) ) {
88+
foreach ( $matches[1] as $candidate ) {
89+
if ( self::is_google_stylesheet_url( $candidate ) ) {
90+
$imports[] = $candidate;
91+
}
92+
}
93+
}
94+
}
95+
$imports = array_values( array_unique( $imports ) );
96+
if ( '' === $fallback_url && ! empty( $imports ) ) {
97+
$fallback_url = $imports[0];
98+
}
99+
$css_lines = array();
100+
foreach ( $imports as $import_url ) {
101+
$css_lines[] = '@import "' . addcslashes( $import_url, "\"\\\n" ) . '";';
102+
}
103+
if ( empty( $css_lines ) && '' !== $fallback_url ) {
104+
$css_lines[] = '@import "' . addcslashes( $fallback_url, "\"\\\n" ) . '";';
105+
}
106+
$css_body = empty( $css_lines ) ? '' : trim( implode( "\n", $css_lines ) ) . "\n";
107+
$outer_detail = array(
108+
'reason' => (string) $font_faces['reason'],
109+
'observed_bytes' => (int) $font_faces['observed_bytes'],
110+
'url' => $preserved_url,
111+
);
112+
if ( 'google_fonts_payloads_partial_preserved' === $outer_detail['reason'] ) {
113+
$outer_detail['limit_bytes'] = self::TOTAL_FONT_LIMIT;
114+
$outer_detail['aggregate_bytes'] = (int) ( $font_faces['aggregate_bytes'] ?? $font_faces['observed_bytes'] );
115+
} elseif ( 'google_fonts_stylesheet_preserved_due_to_size' === $outer_detail['reason'] ) {
116+
$outer_detail['limit_bytes'] = self::CSS_LIMIT;
117+
}
118+
$diagnostics[] = self::diagnostic_with_detail( 'font_materialization_partial_preserved', $outer_detail );
119+
if ( '' === $css_body ) {
120+
return new WP_Error( 'static_site_importer_font_materialization_failed', '', $diagnostics );
121+
}
122+
$writes[] = self::write( 'assets/css/embedded-fonts.css', $css_body, 'theme.font_materialization' );
123+
return self::with_runtime_registration( $writes, $resolved_plan, array(), $diagnostics );
124+
}
125+
$embedded_css = (string) $font_faces['css'];
126+
if ( '' === trim( $embedded_css ) ) {
82127
return new WP_Error( 'static_site_importer_font_materialization_failed', '', $diagnostics );
83128
}
84-
85-
$writes[] = self::write( 'assets/css/embedded-fonts.css', trim( $font_faces ) . "\n", 'theme.font_materialization' );
129+
$writes[] = self::write( 'assets/css/embedded-fonts.css', trim( $embedded_css ) . "\n", 'theme.font_materialization' );
86130
foreach ( $svg_writes as $svg_write ) {
87131
$writes[] = self::write(
88132
$svg_write['target_path'],
89-
self::embed_svg_font_faces( $svg_write['content'], $font_faces ),
133+
self::embed_svg_font_faces( $svg_write['content'], $embedded_css ),
90134
$svg_write['source_path']
91135
);
92136
}
@@ -532,8 +576,11 @@ private static function matching_svg_writes( array $writes, array $families ): a
532576
return $matches;
533577
}
534578

535-
/** @param array<int,string> $families @param array<int,array<string,string>> $diagnostics */
536-
private static function resolve_google_font_faces( array $plan, array $families, array &$diagnostics ): string {
579+
/** @param array<int,string> $families @param array<int,array<string,string>> $diagnostics
580+
* @return array{state:'embedded',css:string}
581+
* | array{state:'preserved',reason:string,observed_bytes:int,url:string,aggregate_bytes?:int}
582+
*/
583+
private static function resolve_google_font_faces( array $plan, array $families, array &$diagnostics ): array {
537584
$imports = array();
538585
foreach ( $plan['stylesheets'] ?? array() as $stylesheet ) {
539586
$content = is_array( $stylesheet ) && is_scalar( $stylesheet['content'] ?? null ) ? (string) $stylesheet['content'] : '';
@@ -544,12 +591,22 @@ private static function resolve_google_font_faces( array $plan, array $families,
544591
$imports = array_values( array_unique( $imports ) );
545592
if ( empty( $imports ) ) {
546593
$diagnostics[] = self::diagnostic( 'stylesheet_import_missing' );
547-
return '';
594+
return array(
595+
'state' => 'preserved',
596+
'reason' => 'stylesheet_import_missing',
597+
'observed_bytes' => 0,
598+
'url' => '',
599+
);
548600
}
549601
foreach ( $imports as $import ) {
550602
if ( ! self::is_google_stylesheet_url( $import ) ) {
551603
$diagnostics[] = self::diagnostic( 'untrusted_stylesheet_url' );
552-
return '';
604+
return array(
605+
'state' => 'preserved',
606+
'reason' => 'untrusted_stylesheet_url',
607+
'observed_bytes' => 0,
608+
'url' => (string) $import,
609+
);
553610
}
554611
}
555612

@@ -560,58 +617,142 @@ private static function resolve_google_font_faces( array $plan, array $families,
560617
$response = self::request( $import, self::CSS_LIMIT );
561618
$css = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
562619
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $css ) {
563-
$diagnostics[] = self::diagnostic( 'stylesheet_fetch_failed' );
564-
return '';
620+
$diagnostics[] = self::diagnostic_with_detail(
621+
'stylesheet_fetch_failed',
622+
array(
623+
'url' => $import,
624+
'observed_bytes' => strlen( $css ),
625+
'limit_bytes' => self::CSS_LIMIT,
626+
)
627+
);
628+
return array(
629+
'state' => 'preserved',
630+
'reason' => 'stylesheet_fetch_failed',
631+
'observed_bytes' => strlen( $css ),
632+
'url' => $import,
633+
);
565634
}
566635
if ( strlen( $css ) > self::CSS_LIMIT ) {
567-
$diagnostics[] = self::diagnostic( 'stylesheet_response_too_large' );
568-
return '';
636+
$diagnostics[] = self::diagnostic_with_detail(
637+
'google_fonts_stylesheet_preserved_due_to_size',
638+
array(
639+
'url' => $import,
640+
'observed_bytes' => strlen( $css ),
641+
'limit_bytes' => self::CSS_LIMIT,
642+
)
643+
);
644+
return array(
645+
'state' => 'preserved',
646+
'reason' => 'google_fonts_stylesheet_preserved_due_to_size',
647+
'observed_bytes' => strlen( $css ),
648+
'url' => $import,
649+
);
569650
}
570651
$embedded = self::embed_font_sources( $css, $families, $font_payloads, $font_payload_bytes, $diagnostics );
571-
if ( '' === $embedded ) {
572-
return '';
652+
if ( 'preserved' === $embedded['state'] ) {
653+
if ( '' === (string) $embedded['url'] ) {
654+
$embedded['url'] = $import;
655+
}
656+
return $embedded;
573657
}
574-
$faces[] = $embedded;
658+
$faces[] = (string) $embedded['css'];
575659
}
576-
return implode( "\n", $faces );
660+
return array(
661+
'state' => 'embedded',
662+
'css' => implode( "\n", $faces ),
663+
);
577664
}
578665

579-
/** @param array<int,string> $families @param array<string,string> $payloads @param array<int,array<string,string>> $diagnostics */
580-
private static function embed_font_sources( string $css, array $families, array &$payloads, int &$payload_bytes, array &$diagnostics ): string {
666+
/** @param array<int,string> $families @param array<string,string> $payloads @param array<int,array<string,string>> $diagnostics
667+
* @return array{state:'embedded',css:string}
668+
* | array{state:'preserved',reason:string,observed_bytes:int,url:string,aggregate_bytes?:int}
669+
*/
670+
private static function embed_font_sources( string $css, array $families, array &$payloads, int &$payload_bytes, array &$diagnostics ): array {
581671
if ( ! preg_match_all( '/@font-face\s*\{([^{}]*)\}/is', $css, $faces ) ) {
582-
$diagnostics[] = self::diagnostic( 'stylesheet_font_faces_missing' );
583-
return '';
672+
$diagnostics[] = self::diagnostic_with_detail(
673+
'stylesheet_font_faces_missing',
674+
array(
675+
'observed_bytes' => strlen( $css ),
676+
'limit_bytes' => self::CSS_LIMIT,
677+
)
678+
);
679+
return array(
680+
'state' => 'preserved',
681+
'reason' => 'stylesheet_font_faces_missing',
682+
'observed_bytes' => strlen( $css ),
683+
'url' => '',
684+
);
584685
}
585-
$embedded = array();
686+
$embedded = array();
687+
$current_source_url = '';
586688
foreach ( $faces[0] as $index => $face ) {
689+
$current_source_url = '';
587690
if ( ! preg_match( '/font-family\s*:\s*(["\']?)([^;"\']+)\1\s*;/i', $faces[1][ $index ], $family ) || ! in_array( trim( $family[2] ), $families, true ) ) {
588691
continue;
589692
}
590693
if ( str_contains( $face, '<' ) || ! preg_match_all( '/url\(\s*(["\']?)([^"\'\s\)]+)\1\s*\)/i', $face, $urls ) ) {
591694
$diagnostics[] = self::diagnostic( 'untrusted_font_url' );
592-
return '';
695+
return array(
696+
'state' => 'preserved',
697+
'reason' => 'untrusted_font_url',
698+
'observed_bytes' => 0,
699+
'url' => $current_source_url,
700+
);
593701
}
594702
$rewritten = $face;
595703
foreach ( array_unique( $urls[2] ) as $url ) {
596-
$parts = wp_parse_url( $url );
597-
$path = is_array( $parts ) ? strtolower( (string) ( $parts['path'] ?? '' ) ) : '';
704+
$current_source_url = $url;
705+
$parts = wp_parse_url( $url );
706+
$path = is_array( $parts ) ? strtolower( (string) ( $parts['path'] ?? '' ) ) : '';
598707
if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || 'fonts.gstatic.com' !== strtolower( (string) ( $parts['host'] ?? '' ) ) || ( isset( $parts['port'] ) && 443 !== (int) $parts['port'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) || ! preg_match( '/\.woff2?$/', $path ) ) {
599708
$diagnostics[] = self::diagnostic( 'untrusted_font_url' );
600-
return '';
709+
return array(
710+
'state' => 'preserved',
711+
'reason' => 'untrusted_font_url',
712+
'observed_bytes' => 0,
713+
'url' => $url,
714+
);
601715
}
602716
if ( ! isset( $payloads[ $url ] ) ) {
603717
$response = self::request( $url, self::FONT_LIMIT );
604718
$payload = is_wp_error( $response ) ? '' : (string) wp_remote_retrieve_body( $response );
605-
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $payload || strlen( $payload ) > self::FONT_LIMIT ) {
606-
$diagnostics[] = self::diagnostic( 'font_payload_fetch_failed' );
607-
return '';
719+
$observed = strlen( $payload );
720+
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) || '' === $payload || $observed > self::FONT_LIMIT ) {
721+
$diagnostics[] = self::diagnostic_with_detail(
722+
'font_payload_fetch_failed',
723+
array(
724+
'url' => $url,
725+
'observed_bytes' => $observed,
726+
'limit_bytes' => self::FONT_LIMIT,
727+
)
728+
);
729+
return array(
730+
'state' => 'preserved',
731+
'reason' => 'font_payload_fetch_failed',
732+
'observed_bytes' => $observed,
733+
'url' => $url,
734+
);
608735
}
609-
if ( self::TOTAL_FONT_LIMIT < $payload_bytes + strlen( $payload ) ) {
610-
$diagnostics[] = self::diagnostic( 'font_payload_total_too_large' );
611-
return '';
736+
if ( self::TOTAL_FONT_LIMIT < $payload_bytes + $observed ) {
737+
$diagnostics[] = self::diagnostic_with_detail(
738+
'google_fonts_payloads_partial_preserved',
739+
array(
740+
'url' => $url,
741+
'observed_bytes' => $observed,
742+
'aggregate_bytes' => $payload_bytes,
743+
'limit_bytes' => self::TOTAL_FONT_LIMIT,
744+
)
745+
);
746+
return array(
747+
'state' => 'preserved',
748+
'reason' => 'google_fonts_payloads_partial_preserved',
749+
'observed_bytes' => $observed,
750+
'aggregate_bytes' => $payload_bytes,
751+
'url' => $url,
752+
);
612753
}
613754
$payloads[ $url ] = $payload;
614-
$payload_bytes += strlen( $payload );
755+
$payload_bytes += $observed;
615756
}
616757
$mime = str_ends_with( $path, '.woff2' ) ? 'font/woff2' : 'font/woff';
617758
$rewritten = str_replace( $url, 'data:' . $mime . ';base64,' . base64_encode( $payloads[ $url ] ), $rewritten ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Encodes a fetched font asset for a CSS data URL.
@@ -621,7 +762,10 @@ private static function embed_font_sources( string $css, array $families, array
621762
if ( empty( $embedded ) ) {
622763
$diagnostics[] = self::diagnostic( 'matching_font_faces_missing' );
623764
}
624-
return implode( "\n", $embedded );
765+
return array(
766+
'state' => 'embedded',
767+
'css' => implode( "\n", $embedded ),
768+
);
625769
}
626770

627771
private static function request( string $url, int $limit ) {
@@ -828,4 +972,14 @@ private static function diagnostic( string $reason ): array {
828972
'reason' => $reason,
829973
);
830974
}
975+
976+
/** @return array{type:string,source:string,reason:string,details:array<string,mixed>} */
977+
private static function diagnostic_with_detail( string $reason, array $details ): array {
978+
return array(
979+
'type' => 'font_materialization_failed',
980+
'source' => 'static-site-importer/font-materializer',
981+
'reason' => $reason,
982+
'details' => $details,
983+
);
984+
}
831985
}

test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
{ "path": "tests/smoke-validation-runtime-diagnostics.php", "environment": "standalone-php" },
3939
{ "path": "tests/smoke-visual-repair-css.php", "environment": "standalone-php" },
4040
{ "path": "tests/smoke-webfont-producer-consumer.php", "environment": "standalone-php" },
41+
{ "path": "tests/smoke-google-fonts-cap-fallback.php", "environment": "standalone-php" },
4142
{ "path": "tests/smoke-website-artifact-import-input.php", "environment": "standalone-php" },
4243
{ "path": "tests/smoke-wordpress-site-plan-materializer.php", "environment": "standalone-php" },
4344
{ "path": "tests/smoke-artifact-run-primitives.php", "environment": "standalone-php" },

0 commit comments

Comments
 (0)