pty( $this->generated_fallbacks ) ) { return $html; } return preg_replace_callback( '/]*)>(.*?)<\/style>/is', function ( $matches ) { $attributes = $matches[1]; $css = $matches[2]; // Skip our own injected style tag. if ( strpos( $attributes, 'rocket-font-fallback-css' ) !== false ) { return $matches[0]; } // Skip font definition style tags (wp-fonts-local class). if ( strpos( $attributes, 'wp-fonts-local' ) !== false ) { return $matches[0]; } $modified_css = $this->process_css_content( $css ); return '' . $modified_css . ''; }, $html ); } /** * Modify font-family declarations in inline style attributes. * * @param string $html HTML content. * @return string Modified HTML. */ private function modify_inline_styles( string $html ): string { if ( empty( $this->generated_fallbacks ) ) { return $html; } // Handle double-quoted style attributes (may contain single quotes inside). $html = preg_replace_callback( '/style\s*=\s*"([^"]*)"/i', function ( $matches ) { $style = $matches[1]; // Only process if contains font-family. if ( stripos( $style, 'font-family' ) === false ) { return $matches[0]; } $modified_style = $this->process_css_content( $style ); return 'style="' . $modified_style . '"'; }, $html ); // Handle single-quoted style attributes (may contain double quotes inside). return preg_replace_callback( "/style\s*=\s*'([^']*)'/i", function ( $matches ) { $style = $matches[1]; // Only process if contains font-family. if ( stripos( $style, 'font-family' ) === false ) { return $matches[0]; } $modified_style = $this->process_css_content( $style ); return "style='" . $modified_style . "'"; }, $html ); } /** * Process CSS content and add fallbacks to font-family declarations. * * @param string $css CSS content. * @return string Modified CSS. */ private function process_css_content( string $css ): string { // First, protect @font-face rules from modification by replacing them with placeholders. $font_face_rules = []; $css = preg_replace_callback( '/@font-face\s*\{[^}]+\}/is', function ( $matches ) use ( &$font_face_rules ) { $placeholder = '/*ROCKET_FONT_FACE_' . count( $font_face_rules ) . '*/'; $font_face_rules[ $placeholder ] = $matches[0]; return $placeholder; }, $css ); // Process CSS custom properties that define font stacks (WordPress pattern). $css = $this->process_css_font_variables( $css ); // Process regular font-family declarations. $css = preg_replace_callback( '/font-family\s*:\s*([^;}]+)/i', function ( $matches ) { $original_value = $matches[1]; // Skip unsafe declarations. if ( $this->is_unsafe_font_declaration( $original_value ) ) { return $matches[0]; } $modified_value = $this->add_fallback_to_font_stack( $original_value ); // Only modify if changed. if ( $modified_value === $original_value ) { return $matches[0]; } return 'font-family: ' . $modified_value; }, $css ); // Restore @font-face rules. foreach ( $font_face_rules as $placeholder => $rule ) { $css = str_replace( $placeholder, $rule, $css ); } return $css; } /** * Process CSS custom properties that define font stacks. * * Handles WordPress pattern like: * --wp--preset--font-family--source-serif-pro: "Source Serif Pro", serif * * @param string $css CSS content. * @return string Modified CSS. */ private function process_css_font_variables( string $css ): string { // Match CSS custom properties with "font" in the name that contain font stacks. // Pattern: --some-font-variable: "Font Name", fallback. return preg_replace_callback( '/(--[a-zA-Z0-9_-]*font[a-zA-Z0-9_-]*)\s*:\s*([^;}]+)/i', function ( $matches ) { $property_name = $matches[1]; $original_value = $matches[2]; // Skip unsafe declarations. if ( $this->is_unsafe_font_declaration( $original_value ) ) { return $matches[0]; } // Process if contains quoted font name OR known unquoted font name. // Handles: "Roboto", sans-serif / "Roboto" / Poppins, Serif. $has_quoted_font = preg_match( '/["\'][^"\']+["\']/', $original_value ); $has_known_font = $this->contains_known_font( $original_value ); if ( ! $has_quoted_font && ! $has_known_font ) { return $matches[0]; } $modified_value = $this->add_fallback_to_font_stack( $original_value ); // Only modify if changed. if ( $modified_value === $original_value ) { return $matches[0]; } return $property_name . ': ' . $modified_value; }, $css ); } /** * Check if a value contains any known font name from generated fallbacks. * * @param string $value CSS value to check. * @return bool True if contains known font. */ private function contains_known_font( string $value ): bool { if ( empty( $this->generated_fallbacks ) ) { return false; } foreach ( array_keys( $this->generated_fallbacks ) as $font_name ) { // Case-insensitive word boundary match. if ( preg_match( '/\b' . preg_quote( $font_name, '/' ) . '\b/i', $value ) ) { return true; } } return false; } /** * Check if a font-family declaration is unsafe to modify. * * @param string $value Font-family value. * @return bool True if unsafe. */ private function is_unsafe_font_declaration( string $value ): bool { // Skip CSS variables. if ( stripos( $value, 'var(' ) !== false ) { return true; } // Skip CSS comments. if ( strpos( $value, '/*' ) !== false ) { return true; } // Skip if already has our fallback. if ( strpos( $value, '-fallback' ) !== false ) { return true; } // Skip if contains calc() or other functions. if ( preg_match( '/\b(calc|env|min|max|clamp)\s*\(/i', $value ) ) { return true; } return false; } /** * Add fallback font to a font stack. * * @param string $font_stack Original font-family value. * @return string Modified font stack. */ private function add_fallback_to_font_stack( string $font_stack ): string { $processed_fonts = array_keys( $this->generated_fallbacks ); if ( empty( $processed_fonts ) ) { return $font_stack; } // Sort by length descending to process longer names first. // This prevents "Roboto" from matching inside "Roboto Slab". usort( $processed_fonts, function ( $a, $b ) { return strlen( $b ) - strlen( $a ); } ); foreach ( $processed_fonts as $font_name ) { $fallback_name = $this->get_fallback_family_name( $font_name ); $escaped_name = preg_quote( $font_name, '/' ); // Try quoted patterns first (most reliable). $patterns = [ '/"' . $escaped_name . '"/' => '"', "/'" . $escaped_name . "'/" => "'", ]; $matched = false; foreach ( $patterns as $pattern => $quote ) { if ( preg_match( $pattern, $font_stack ) ) { $replacement = $quote . $font_name . $quote . ', ' . $quote . $fallback_name . $quote; $font_stack = preg_replace( $pattern, $replacement, $font_stack, 1 ); $matched = true; break; } } // Try unquoted only if no quotes matched. // Negative lookahead prevents matching: // - "Roboto" in "Roboto Slab" (space + letter) // - "Roboto" in "Roboto-Slab-fallback" (hyphen). if ( ! $matched ) { $unquoted_pattern = '/\b' . $escaped_name . '\b(?!-|\s+[A-Za-z])/'; if ( preg_match( $unquoted_pattern, $font_stack ) ) { $replacement = $font_name . ", '" . $fallback_name . "'"; $font_stack = preg_replace( $unquoted_pattern, $replacement, $font_stack, 1 ); } } } return $font_stack; } /** * Get the font family stack with fallback. * * Generates a font-family value that includes the fallback font. * * @param string $font_name Original font name. * @return string|null Font family stack or null. */ public function get_font_family_with_fallback( string $font_name ): ?string { if ( ! $this->font_metrics->has_font( $font_name ) ) { return null; } $font_data = $this->font_metrics->get_font_metrics( $font_name ); $fallback_name = $this->get_fallback_family_name( $font_name ); $category = $font_data['category'] ?? 'sans-serif'; // Generic fallback based on category. $generic = $category; if ( ! in_array( $category, [ 'serif', 'sans-serif', 'monospace' ], true ) ) { $generic = 'sans-serif'; } return "'{$font_name}', '{$fallback_name}', {$generic}"; } /** * Get list of fonts that have been processed. * * @return array Font names. */ public function get_processed_fonts(): array { return array_keys( $this->generated_fallbacks ); } /** * Clear the generated fallbacks cache. * * @return void */ public function clear_cache(): void { $this->generated_fallbacks = []; } }