monorepo/native/wordpress/maple-fonts-wp/includes/class-mlf-font-downloader.php
rodolfomartinez 847ed92c23 v1-pre
2026-02-02 00:13:36 -05:00

668 lines
21 KiB
PHP

<?php
/**
* Font Downloader for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Downloader
*
* Handles downloading fonts from Google Fonts CSS2 API.
* Attempts variable fonts first, falls back to static fonts.
*/
class MLF_Font_Downloader {
/**
* User agent to send with requests (needed to get WOFF2 format).
*
* @var string
*/
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';
/**
* All available font weights.
*
* @var array
*/
private $all_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
/**
* Download a font from Google Fonts.
*
* Attempts variable font first, falls back to static if 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.
*/
public function download($font_name, $include_italic = true) {
// Validate font name
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid font name');
}
if (strlen($font_name) > 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;
}
}