monorepo/native/wordpress/maple-fonts-wp/GOOGLE_FONTS_API.md
2026-01-30 22:33:40 -05:00

21 KiB

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

/**
 * 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:

$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

/**
 * 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:

/* 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

/**
 * 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

/**
 * 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

/**
 * 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)

/**
 * 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
if (!defined('ABSPATH')) {
    exit;
}

class MLF_Font_Downloader {

    private $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';

    /**
     * Download a font from Google Fonts.
     *
     * @param string $font_name Font family name
     * @param array  $weights   Weights to download
     * @param array  $styles    Styles to download
     * @return array|WP_Error Download result or error
     */
    public function download($font_name, $weights, $styles) {
        // Validate inputs
        if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
            return new WP_Error('invalid_name', 'Invalid font name');
        }
        
        $weights = array_intersect(array_map('absint', $weights), [100,200,300,400,500,600,700,800,900]);
        if (empty($weights)) {
            return new WP_Error('invalid_weights', 'No valid weights specified');
        }
        
        $styles = array_intersect($styles, ['normal', 'italic']);
        if (empty($styles)) {
            return new WP_Error('invalid_styles', 'No valid styles specified');
        }
        
        // Fetch CSS
        $css = $this->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)