# GOOGLE_FONTS_API.md — Google Fonts Retrieval Guide ## Overview This document covers how to retrieve fonts from Google Fonts CSS2 API. This is a **one-time retrieval** during admin import — after download, fonts are served locally and Google is never contacted again. --- ## The Retrieval Flow ``` 1. Admin enters "Open Sans" + selects weights/styles ↓ 2. Plugin constructs Google Fonts CSS2 URL ↓ 3. Plugin fetches CSS (contains @font-face rules with WOFF2 URLs) ↓ 4. Plugin parses CSS to extract WOFF2 URLs ↓ 5. Plugin downloads each WOFF2 file to wp-content/fonts/ ↓ 6. Plugin registers font with WordPress Font Library ↓ 7. DONE - Google never contacted again ``` --- ## Google Fonts CSS2 API ### Base URL ``` https://fonts.googleapis.com/css2 ``` ### URL Construction **Pattern:** ``` https://fonts.googleapis.com/css2?family={Font+Name}:ital,wght@{variations}&display=swap ``` **Variations format:** ``` ital,wght@{italic},{weight};{italic},{weight};... ``` Where: - `ital` = 0 (normal) or 1 (italic) - `wght` = weight (100-900) ### Examples **Open Sans 400 normal only:** ``` https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap ``` **Open Sans 400 + 700 normal:** ``` https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap ``` **Open Sans 400 + 700, both normal and italic:** ``` https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap ``` **Breakdown of `ital,wght@0,400;0,700;1,400;1,700`:** - `0,400` = normal 400 - `0,700` = normal 700 - `1,400` = italic 400 - `1,700` = italic 700 ### URL Builder Function ```php /** * Build Google Fonts CSS2 API URL. * * @param string $font_name Font family name (e.g., "Open Sans") * @param array $weights Array of weights (e.g., [400, 700]) * @param array $styles Array of styles (e.g., ['normal', 'italic']) * @return string Google Fonts CSS2 URL */ function mlf_build_google_fonts_url($font_name, $weights, $styles) { // URL-encode font name (spaces become +) $family = str_replace(' ', '+', $font_name); // Build variations $variations = []; // Sort for consistent URLs sort($weights); sort($styles); $has_italic = in_array('italic', $styles, true); $has_normal = in_array('normal', $styles, true); foreach ($weights as $weight) { if ($has_normal) { $variations[] = "0,{$weight}"; } if ($has_italic) { $variations[] = "1,{$weight}"; } } // If only normal styles, simpler format if ($has_normal && !$has_italic) { $wght = implode(';', $weights); return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap"; } // Full format with ital axis $variation_string = implode(';', $variations); return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap"; } ``` --- ## CRITICAL: User-Agent Requirement Google Fonts returns **different file formats** based on user-agent: | User-Agent | Format Returned | |------------|-----------------| | Old browser | TTF or WOFF | | Modern browser | WOFF2 | | curl (no UA) | TTF | **We need WOFF2** (smallest, best compression, modern browser support). ### Required User-Agent Use a modern Chrome user-agent: ```php $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; ``` ### Fetch Function ```php /** * Fetch 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 */ function mlf_fetch_google_css($font_name, $weights, $styles) { $url = mlf_build_google_fonts_url($font_name, $weights, $styles); // Validate URL if (!mlf_is_valid_google_fonts_url($url)) { return new WP_Error('invalid_url', 'Invalid Google Fonts URL'); } // CRITICAL: Must use modern browser user-agent to get WOFF2 $response = wp_remote_get($url, [ 'timeout' => 15, 'sslverify' => true, 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ]); if (is_wp_error($response)) { return new WP_Error( 'request_failed', 'Could not connect to Google Fonts: ' . $response->get_error_message() ); } $status = wp_remote_retrieve_response_code($response); if ($status === 400) { return new WP_Error('font_not_found', 'Font not found on Google Fonts'); } if ($status !== 200) { return new WP_Error('http_error', 'Google Fonts returned HTTP ' . $status); } $css = wp_remote_retrieve_body($response); if (empty($css)) { return new WP_Error('empty_response', 'Empty response from Google Fonts'); } // Verify we got WOFF2 (sanity check) if (strpos($css, '.woff2)') === false) { return new WP_Error('wrong_format', 'Did not receive WOFF2 format - check user-agent'); } return $css; } ``` --- ## Parsing the CSS Response ### Sample Response Google returns CSS like this: ```css /* latin-ext */ @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 400; font-stretch: 100%; font-display: swap; src: url(https://fonts.gstatic.com/s/opensans/v40/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsg-1x4gaVI.woff2) format('woff2'); unicode-range: U+0100-02AF, U+0304, U+0308, ...; } /* latin */ @font-face { font-family: 'Open Sans'; font-style: normal; font-weight: 400; font-stretch: 100%; font-display: swap; src: url(https://fonts.gstatic.com/s/opensans/v40/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVI.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, ...; } ``` ### Key Observations 1. **Multiple @font-face blocks per weight** — Google splits fonts by unicode subset (latin, latin-ext, cyrillic, etc.) 2. **We want the "latin" subset** — It's the base and covers most use cases 3. **Each block has:** font-family, font-style, font-weight, src URL, unicode-range ### Unicode Subset Strategy **Option A: Download only latin (simpler, smaller)** - Parse CSS, identify latin blocks, download only those - Good for most sites **Option B: Download all subsets (more complete)** - Download all WOFF2 files - Larger but supports more languages **Recommended: Option A** — Start with latin subset. Can add option for more subsets later. ### Parsing Function ```php /** * Parse Google Fonts CSS and extract font face data. * * @param string $css CSS content from Google Fonts * @param string $font_name Expected font family name * @return array|WP_Error Array of font face data or error */ function mlf_parse_google_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', 'Could not parse CSS - no @font-face rules found'); } foreach ($matches[1] as $block) { $face_data = mlf_parse_font_face_block($block); if (is_wp_error($face_data)) { continue; // Skip malformed blocks } // Verify font family matches (security) if (strcasecmp($face_data['family'], $font_name) !== 0) { continue; } // Create unique key for weight+style combo $key = $face_data['weight'] . '-' . $face_data['style']; // Prefer latin subset (usually comes after latin-ext) // Check if this is a latin block by unicode-range $is_latin = mlf_is_latin_subset($face_data['unicode_range']); // Only store if: // 1. We don't have this weight/style yet, OR // 2. This is latin and replaces non-latin 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 in CSS'); } return array_values($font_faces); } /** * Parse a single @font-face block. * * @param string $block Content inside @font-face { } * @return array|WP_Error Parsed data or error */ function mlf_parse_font_face_block($block) { $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 if (preg_match('/font-weight:\s*(\d+);/i', $block, $m)) { $data['weight'] = $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'; // Default } // 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 (optional, for subset detection) 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. * Latin typically starts with U+0000-00FF. * * @param string $range Unicode range string * @return bool True if appears to be latin subset */ function mlf_is_latin_subset($range) { // Latin subset typically includes basic ASCII range // and does NOT include extended Latin (U+0100+) as primary if (empty($range)) { return true; // Assume latin if no range specified } // Latin subset usually starts with U+0000 and includes U+00FF // Latin-ext starts with U+0100 if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) { return true; } return false; } ``` --- ## Downloading WOFF2 Files ### Download Function ```php /** * Download a single WOFF2 file from Google Fonts. * * @param string $url Google Fonts static URL * @param string $font_slug Font slug (e.g., "open-sans") * @param string $weight Font weight (e.g., "400") * @param string $style Font style (e.g., "normal") * @return string|WP_Error Local file path or error */ function mlf_download_font_file($url, $font_slug, $weight, $style) { // Validate URL is from Google if (!mlf_is_valid_google_fonts_url($url)) { return new WP_Error('invalid_url', 'URL is not from Google Fonts'); } // Build local filename $filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight); $filename = sanitize_file_name($filename); // Get destination path $font_dir = wp_get_font_dir(); $destination = trailingslashit($font_dir['path']) . $filename; // Validate destination path if (!mlf_validate_font_path($destination)) { return new WP_Error('invalid_path', 'Invalid destination path'); } // Ensure directory exists if (!wp_mkdir_p($font_dir['path'])) { return new WP_Error('mkdir_failed', 'Could not create fonts directory'); } // Download file $response = wp_remote_get($url, [ 'timeout' => 30, 'sslverify' => true, 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', ]); if (is_wp_error($response)) { return new WP_Error( 'download_failed', 'Failed to download font file: ' . $response->get_error_message() ); } $status = wp_remote_retrieve_response_code($response); if ($status !== 200) { return new WP_Error('http_error', 'Font download returned HTTP ' . $status); } $content = wp_remote_retrieve_body($response); if (empty($content)) { return new WP_Error('empty_file', 'Downloaded font file is empty'); } // Verify it looks like a WOFF2 file (magic bytes: wOF2) if (substr($content, 0, 4) !== 'wOF2') { return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2'); } // Write file using WP Filesystem 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; } ``` ### Batch Download Function ```php /** * Download all font files for a font family. * * @param string $font_name Font family name * @param array $weights Weights to download * @param array $styles Styles to download * @return array|WP_Error Array of downloaded files or error */ function mlf_download_font_family($font_name, $weights, $styles) { // Fetch CSS from Google $css = mlf_fetch_google_css($font_name, $weights, $styles); if (is_wp_error($css)) { return $css; } // Parse CSS to get font face data $font_faces = mlf_parse_google_css($css, $font_name); if (is_wp_error($font_faces)) { return $font_faces; } // Generate slug from font name $font_slug = sanitize_title($font_name); // Download each font file $downloaded = []; $errors = []; foreach ($font_faces as $face) { $result = mlf_download_font_file( $face['url'], $font_slug, $face['weight'], $face['style'] ); if (is_wp_error($result)) { $errors[] = $result->get_error_message(); continue; } $downloaded[] = [ 'path' => $result, 'weight' => $face['weight'], 'style' => $face['style'], ]; } // If no files downloaded, return error if (empty($downloaded)) { return new WP_Error( 'download_failed', 'Could not download any font files: ' . implode(', ', $errors) ); } return [ 'font_name' => $font_name, 'font_slug' => $font_slug, 'files' => $downloaded, ]; } ``` --- ## Error Handling ### Common Errors | Error | Cause | User Message | |-------|-------|--------------| | Font not found | Typo in font name | "Font not found on Google Fonts. Check the spelling." | | Network timeout | Slow connection | "Could not connect to Google Fonts. Please try again." | | Invalid format | Wrong user-agent | Internal error - should not happen | | Write failed | Permissions | "Could not save font files. Check directory permissions." | ### Error Messages (User-Friendly) ```php /** * Convert internal error codes to user-friendly messages. * * @param WP_Error $error The error object * @return string User-friendly message */ function mlf_get_user_error_message($error) { $code = $error->get_error_code(); $messages = [ 'font_not_found' => 'Font not found on Google Fonts. Please check the spelling and try again.', 'request_failed' => 'Could not connect to Google Fonts. Please check your internet connection and try again.', 'http_error' => 'Google Fonts returned an error. Please try again later.', 'parse_failed' => 'Could not process the font data. The font may not be available.', 'download_failed' => 'Could not download the font files. Please try again.', 'write_failed' => 'Could not save font files. Please check that wp-content/fonts is writable.', 'mkdir_failed' => 'Could not create fonts directory. Please check file permissions.', 'invalid_path' => 'Invalid file path. Please contact support.', 'invalid_url' => 'Invalid font URL. Please contact support.', ]; return $messages[$code] ?? 'An unexpected error occurred. Please try again.'; } ``` --- ## Complete Downloader Class ```php fetch_css($font_name, $weights, $styles); if (is_wp_error($css)) { return $css; } // Parse CSS $font_faces = $this->parse_css($css, $font_name); if (is_wp_error($font_faces)) { return $font_faces; } // Download files $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, ]; } private function build_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"; } private function fetch_css($font_name, $weights, $styles) { $url = $this->build_url($font_name, $weights, $styles); $response = wp_remote_get($url, [ 'timeout' => 15, '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) || strpos($css, '.woff2)') === false) { return new WP_Error('invalid_response', 'Invalid CSS response'); } return $css; } private function parse_css($css, $font_name) { // Implementation as shown above // Returns array of font face data } private function download_files($font_faces, $font_slug) { // Implementation as shown above // Returns array of downloaded file info } } ``` --- ## Testing Checklist - [ ] Valid font name (Open Sans) returns CSS with WOFF2 URLs - [ ] Invalid font name returns appropriate error - [ ] Multiple weights are all downloaded - [ ] Italic styles are handled correctly - [ ] Files are saved to correct location - [ ] Files have correct WOFF2 magic bytes - [ ] Timeout handling works (test with slow connection) - [ ] User-agent produces WOFF2 (not TTF/WOFF)