100) { return new WP_Error('invalid_name', 'Font name too long'); } // Try variable font first $result = $this->try_variable_font($font_name, $include_italic); if (!is_wp_error($result)) { return $result; } // Fall back to static fonts return $this->download_static_fonts($font_name, $include_italic); } /** * Attempt to download variable font. * * @param string $font_name Font family name. * @param bool $include_italic Whether to include italic styles. * @return array|WP_Error Download result or error. */ private function try_variable_font($font_name, $include_italic) { $font_slug = sanitize_title($font_name); $downloaded = []; // Try to fetch variable font CSS (roman/upright) $css = $this->fetch_variable_css($font_name, false); if (is_wp_error($css)) { return $css; } // Parse and download roman variable font $roman_faces = $this->parse_variable_css($css, $font_name); if (is_wp_error($roman_faces) || empty($roman_faces)) { return new WP_Error('no_variable', 'Variable font not available'); } // Download roman variable font file(s) foreach ($roman_faces as $face) { $result = $this->download_single_file( $face['url'], $font_slug, $face['weight'], 'normal', true // is_variable ); if (!is_wp_error($result)) { $downloaded[] = [ 'path' => $result, 'weight' => $face['weight'], 'style' => 'normal', 'is_variable' => true, ]; } } // Try italic variable font if requested if ($include_italic) { $italic_css = $this->fetch_variable_css($font_name, true); if (!is_wp_error($italic_css)) { $italic_faces = $this->parse_variable_css($italic_css, $font_name); if (!is_wp_error($italic_faces) && !empty($italic_faces)) { foreach ($italic_faces as $face) { $result = $this->download_single_file( $face['url'], $font_slug, $face['weight'], 'italic', true ); if (!is_wp_error($result)) { $downloaded[] = [ 'path' => $result, 'weight' => $face['weight'], 'style' => 'italic', 'is_variable' => true, ]; } } } } } if (empty($downloaded)) { return new WP_Error('download_failed', 'Could not download variable font files'); } return [ 'font_name' => $font_name, 'font_slug' => $font_slug, 'files' => $downloaded, 'is_variable' => true, ]; } /** * Download static fonts (fallback when variable not available). * * @param string $font_name Font family name. * @param bool $include_italic Whether to include italic styles. * @return array|WP_Error Download result or error. */ private function download_static_fonts($font_name, $include_italic) { $styles = $include_italic ? ['normal', 'italic'] : ['normal']; // Fetch CSS from Google $css = $this->fetch_static_css($font_name, $this->all_weights, $styles); if (is_wp_error($css)) { return $css; } // Parse CSS to get font face data $font_faces = $this->parse_static_css($css, $font_name); if (is_wp_error($font_faces)) { return $font_faces; } // Download each font file $font_slug = sanitize_title($font_name); $downloaded = $this->download_files($font_faces, $font_slug); if (is_wp_error($downloaded)) { return $downloaded; } return [ 'font_name' => $font_name, 'font_slug' => $font_slug, 'files' => $downloaded, 'is_variable' => false, ]; } /** * Fetch variable font CSS from Google Fonts API. * * @param string $font_name Font family name. * @param bool $italic Whether to fetch italic variant. * @return string|WP_Error CSS content or error. */ private function fetch_variable_css($font_name, $italic = false) { $family = str_replace(' ', '+', $font_name); if ($italic) { // Request italic variable font $url = "https://fonts.googleapis.com/css2?family={$family}:ital,wght@1,100..900&display=swap"; } else { // Request roman variable font $url = "https://fonts.googleapis.com/css2?family={$family}:wght@100..900&display=swap"; } return $this->fetch_css($url); } /** * Fetch static font CSS from Google Fonts API. * * @param string $font_name Font family name. * @param array $weights Weights to fetch. * @param array $styles Styles to fetch. * @return string|WP_Error CSS content or error. */ private function fetch_static_css($font_name, $weights, $styles) { $url = $this->build_static_url($font_name, $weights, $styles); return $this->fetch_css($url); } /** * Fetch CSS from a Google Fonts URL. * * @param string $url Google Fonts CSS URL. * @return string|WP_Error CSS content or error. */ private function fetch_css($url) { // Validate URL if (!$this->is_valid_google_fonts_url($url)) { return new WP_Error('invalid_url', 'Invalid Google Fonts URL'); } $response = wp_remote_get($url, [ 'timeout' => MLF_REQUEST_TIMEOUT, 'sslverify' => true, 'user-agent' => $this->user_agent, ]); if (is_wp_error($response)) { return new WP_Error('request_failed', $response->get_error_message()); } $status = wp_remote_retrieve_response_code($response); if ($status === 400) { return new WP_Error('font_not_found', 'Font not found'); } if ($status !== 200) { return new WP_Error('http_error', 'HTTP ' . $status); } $css = wp_remote_retrieve_body($response); if (empty($css)) { return new WP_Error('empty_response', 'Empty response from Google Fonts'); } // Check CSS response size $max_size = defined('MLF_MAX_CSS_SIZE') ? MLF_MAX_CSS_SIZE : 512 * 1024; if (strlen($css) > $max_size) { return new WP_Error('response_too_large', 'CSS response exceeds maximum size limit'); } // Verify we got WOFF2 if (strpos($css, '.woff2)') === false) { return new WP_Error('wrong_format', 'Did not receive WOFF2 format'); } return $css; } /** * Build static font URL. * * @param string $font_name Font family name. * @param array $weights Array of weights. * @param array $styles Array of styles. * @return string Google Fonts CSS2 URL. */ private function build_static_url($font_name, $weights, $styles) { $family = str_replace(' ', '+', $font_name); sort($weights); $has_italic = in_array('italic', $styles, true); $has_normal = in_array('normal', $styles, true); if ($has_normal && !$has_italic) { $wght = implode(';', $weights); return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap"; } $variations = []; foreach ($weights as $weight) { if ($has_normal) { $variations[] = "0,{$weight}"; } if ($has_italic) { $variations[] = "1,{$weight}"; } } $variation_string = implode(';', $variations); return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap"; } /** * Parse variable font CSS. * * @param string $css CSS content. * @param string $font_name Expected font family name. * @return array|WP_Error Array of font face data or error. */ private function parse_variable_css($css, $font_name) { $font_faces = []; // Match all @font-face blocks $pattern = '/@font-face\s*\{([^}]+)\}/s'; if (!preg_match_all($pattern, $css, $matches)) { return new WP_Error('parse_failed', 'No @font-face rules found'); } foreach ($matches[1] as $block) { $face_data = $this->parse_font_face_block($block, true); if (is_wp_error($face_data)) { continue; } // Verify font family matches if (strcasecmp($face_data['family'], $font_name) !== 0) { continue; } // For variable fonts, prefer latin subset $key = $face_data['weight'] . '-' . $face_data['style']; $is_latin = $this->is_latin_subset($face_data['unicode_range']); if (!isset($font_faces[$key]) || $is_latin) { $font_faces[$key] = $face_data; } } return array_values($font_faces); } /** * Parse static font CSS. * * @param string $css CSS content. * @param string $font_name Expected font family name. * @return array|WP_Error Array of font face data or error. */ private function parse_static_css($css, $font_name) { $font_faces = []; $pattern = '/@font-face\s*\{([^}]+)\}/s'; if (!preg_match_all($pattern, $css, $matches)) { return new WP_Error('parse_failed', 'No @font-face rules found'); } foreach ($matches[1] as $block) { $face_data = $this->parse_font_face_block($block, false); if (is_wp_error($face_data)) { continue; } if (strcasecmp($face_data['family'], $font_name) !== 0) { continue; } $key = $face_data['weight'] . '-' . $face_data['style']; $is_latin = $this->is_latin_subset($face_data['unicode_range']); if (!isset($font_faces[$key]) || $is_latin) { $font_faces[$key] = $face_data; } } if (empty($font_faces)) { return new WP_Error('no_fonts', 'No valid font faces found'); } // Limit number of font faces $max_faces = defined('MLF_MAX_FONT_FACES') ? MLF_MAX_FONT_FACES : 20; $result = array_values($font_faces); if (count($result) > $max_faces) { $result = array_slice($result, 0, $max_faces); } return $result; } /** * Parse a single @font-face block. * * @param string $block Content inside @font-face { }. * @param bool $is_variable Whether this is a variable font. * @return array|WP_Error Parsed data or error. */ private function parse_font_face_block($block, $is_variable = false) { $data = []; // Extract font-family if (preg_match('/font-family:\s*[\'"]?([^;\'"]+)[\'"]?;/i', $block, $m)) { $data['family'] = trim($m[1]); } else { return new WP_Error('missing_family', 'Missing font-family'); } // Extract font-weight (can be single value or range for variable) if (preg_match('/font-weight:\s*(\d+(?:\s+\d+)?);/i', $block, $m)) { $data['weight'] = trim($m[1]); } else { return new WP_Error('missing_weight', 'Missing font-weight'); } // Extract font-style if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) { $data['style'] = $m[1]; } else { $data['style'] = 'normal'; } // Extract src URL - MUST be fonts.gstatic.com if (preg_match('/src:\s*url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/i', $block, $m)) { $data['url'] = $m[1]; } else { return new WP_Error('missing_src', 'Missing or invalid src URL'); } // Extract unicode-range if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) { $data['unicode_range'] = trim($m[1]); } else { $data['unicode_range'] = ''; } return $data; } /** * Check if unicode-range indicates latin subset. * * @param string $range Unicode range string. * @return bool True if appears to be latin subset. */ private function is_latin_subset($range) { if (empty($range)) { return true; } if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) { return true; } return false; } /** * Download all font files. * * @param array $font_faces Array of font face data. * @param string $font_slug Font slug for filename. * @return array|WP_Error Array of downloaded file info or error. */ private function download_files($font_faces, $font_slug) { $downloaded = []; $errors = []; foreach ($font_faces as $face) { $result = $this->download_single_file( $face['url'], $font_slug, $face['weight'], $face['style'], false ); if (is_wp_error($result)) { $errors[] = $result->get_error_message(); continue; } $downloaded[] = [ 'path' => $result, 'weight' => $face['weight'], 'style' => $face['style'], 'is_variable' => false, ]; } if (empty($downloaded)) { return new WP_Error( 'download_failed', 'Could not download any font files: ' . implode(', ', $errors) ); } return $downloaded; } /** * Download a single WOFF2 file. * * @param string $url Google Fonts static URL. * @param string $font_slug Font slug for filename. * @param string $weight Font weight (single or range). * @param string $style Font style. * @param bool $is_variable Whether this is a variable font. * @return string|WP_Error Local file path or error. */ private function download_single_file($url, $font_slug, $weight, $style, $is_variable = false) { if (!$this->is_valid_google_fonts_url($url)) { return new WP_Error('invalid_url', 'URL is not from Google Fonts'); } // Build filename $weight_slug = str_replace(' ', '-', $weight); if ($is_variable) { $filename = sprintf('%s_%s_variable.woff2', $font_slug, $style); } else { $filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight_slug); } $filename = sanitize_file_name($filename); $filename = $this->sanitize_font_filename($filename); if ($filename === false) { return new WP_Error('invalid_filename', 'Invalid filename'); } $font_dir = wp_get_font_dir(); $destination = trailingslashit($font_dir['path']) . $filename; if (!$this->validate_font_path($destination)) { return new WP_Error('invalid_path', 'Invalid destination path'); } if (!wp_mkdir_p($font_dir['path'])) { return new WP_Error('mkdir_failed', 'Could not create fonts directory'); } $response = wp_remote_get($url, [ 'timeout' => MLF_REQUEST_TIMEOUT, 'sslverify' => true, 'user-agent' => $this->user_agent, ]); if (is_wp_error($response)) { return new WP_Error('download_failed', 'Failed to download: ' . $response->get_error_message()); } $status = wp_remote_retrieve_response_code($response); if ($status !== 200) { return new WP_Error('http_error', 'Download returned HTTP ' . $status); } $content = wp_remote_retrieve_body($response); if (empty($content)) { return new WP_Error('empty_file', 'Downloaded file is empty'); } $max_size = defined('MLF_MAX_FONT_FILE_SIZE') ? MLF_MAX_FONT_FILE_SIZE : 5 * 1024 * 1024; if (strlen($content) > $max_size) { return new WP_Error('file_too_large', 'Font file exceeds maximum size'); } // Verify WOFF2 magic bytes if (substr($content, 0, 4) !== 'wOF2') { return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2'); } global $wp_filesystem; if (empty($wp_filesystem)) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) { return new WP_Error('write_failed', 'Could not write font file'); } return $destination; } /** * Validate Google Fonts URL. * * @param string $url URL to validate. * @return bool True if valid. */ private function is_valid_google_fonts_url($url) { $parsed = wp_parse_url($url); if (!$parsed || !isset($parsed['host'])) { return false; } $allowed_hosts = [ 'fonts.googleapis.com', 'fonts.gstatic.com', ]; return in_array($parsed['host'], $allowed_hosts, true); } /** * Sanitize font filename. * * @param string $filename Filename to sanitize. * @return string|false Sanitized filename or false. */ private function sanitize_font_filename($filename) { $filename = sanitize_file_name($filename); if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') { return false; } if ($filename !== basename($filename)) { return false; } if (strlen($filename) > 200) { return false; } return $filename; } /** * Validate font path is within fonts directory. * * @param string $path Path to validate. * @return bool True if valid. */ private function validate_font_path($path) { $font_dir = wp_get_font_dir(); $fonts_path = wp_normalize_path(trailingslashit($font_dir['path'])); $real_path = realpath($path); if ($real_path === false) { $dir = dirname($path); $real_dir = realpath($dir); if ($real_dir === false) { $parent_dir = dirname($dir); $real_parent = realpath($parent_dir); if ($real_parent === false) { return false; } $real_path = wp_normalize_path($real_parent . '/' . basename($dir) . '/' . basename($path)); } else { $real_path = wp_normalize_path($real_dir . '/' . basename($path)); } } else { $real_path = wp_normalize_path($real_path); } return strpos($real_path, $fonts_path) === 0; } }