| 1 |
<?php |
| 2 |
/** |
| 3 |
* @font-face parser. |
| 4 |
* |
| 5 |
* Uses a brace-aware scanner instead of a naive `\{([^}]+)\}` regex, so it |
| 6 |
* survives minified CSS, nested blocks, and data-URI sources. |
| 7 |
* |
| 8 |
* @package EasyFonts |
| 9 |
*/ |
| 10 |
|
| 11 |
namespace EasyFonts\Parser; |
| 12 |
|
| 13 |
defined( 'ABSPATH' ) || exit; |
| 14 |
|
| 15 |
/** |
| 16 |
* Parses @font-face blocks out of arbitrary CSS. |
| 17 |
*/ |
| 18 |
class FontFaceParser { |
| 19 |
|
| 20 |
/** |
| 21 |
* Extract every @font-face block (full text + inner body) from CSS. |
| 22 |
* |
| 23 |
* @param string $css CSS source. |
| 24 |
* @return array<int,array{full:string,body:string}> |
| 25 |
*/ |
| 26 |
public function blocks( string $css ): array { |
| 27 |
$blocks = array(); |
| 28 |
$offset = 0; |
| 29 |
$len = strlen( $css ); |
| 30 |
|
| 31 |
while ( $offset < $len ) { |
| 32 |
$at = stripos( $css, '@font-face', $offset ); |
| 33 |
|
| 34 |
if ( false === $at ) { |
| 35 |
break; |
| 36 |
} |
| 37 |
|
| 38 |
// Find the opening brace after @font-face. |
| 39 |
$brace = strpos( $css, '{', $at ); |
| 40 |
|
| 41 |
if ( false === $brace ) { |
| 42 |
break; |
| 43 |
} |
| 44 |
|
| 45 |
// Walk forward tracking depth so nested braces don't end the block early. |
| 46 |
$depth = 0; |
| 47 |
$end = $brace; |
| 48 |
|
| 49 |
for ( $i = $brace; $i < $len; $i++ ) { |
| 50 |
$ch = $css[ $i ]; |
| 51 |
|
| 52 |
if ( '{' === $ch ) { |
| 53 |
$depth++; |
| 54 |
} elseif ( '}' === $ch ) { |
| 55 |
$depth--; |
| 56 |
|
| 57 |
if ( 0 === $depth ) { |
| 58 |
$end = $i; |
| 59 |
break; |
| 60 |
} |
| 61 |
} |
| 62 |
} |
| 63 |
|
| 64 |
if ( $depth !== 0 ) { |
| 65 |
break; // Unbalanced; bail rather than mis-slice. |
| 66 |
} |
| 67 |
|
| 68 |
$full = substr( $css, $at, ( $end - $at ) + 1 ); |
| 69 |
$body = substr( $css, $brace + 1, ( $end - $brace ) - 1 ); |
| 70 |
|
| 71 |
// Capture an immediately-preceding /* subset */ comment, if any. |
| 72 |
$lead = ''; |
| 73 |
$before = substr( $css, 0, $at ); |
| 74 |
|
| 75 |
if ( preg_match( '/\/\*([^*]*)\*\/\s*$/', $before, $cm ) ) { |
| 76 |
$lead = trim( $cm[1] ); |
| 77 |
} |
| 78 |
|
| 79 |
$blocks[] = array( |
| 80 |
'full' => $full, |
| 81 |
'body' => $body, |
| 82 |
'lead' => $lead, |
| 83 |
); |
| 84 |
|
| 85 |
$offset = $end + 1; |
| 86 |
} |
| 87 |
|
| 88 |
return $blocks; |
| 89 |
} |
| 90 |
|
| 91 |
/** |
| 92 |
* Parse a single @font-face body into structured properties. |
| 93 |
* |
| 94 |
* @param string $body Inner CSS of one @font-face block. |
| 95 |
* @return array{family:string,weight:string,style:string,src:string,unicode_range:string,display:string} |
| 96 |
*/ |
| 97 |
public function properties( string $body ): array { |
| 98 |
return array( |
| 99 |
'family' => $this->prop_value( $body, 'font-family', '', true ), |
| 100 |
'weight' => $this->prop_value( $body, 'font-weight', '400' ), |
| 101 |
'style' => $this->prop_value( $body, 'font-style', 'normal' ), |
| 102 |
'src' => $this->first_src_url( $body ), |
| 103 |
'unicode_range' => $this->prop_value( $body, 'unicode-range', '' ), |
| 104 |
'display' => $this->prop_value( $body, 'font-display', '' ), |
| 105 |
); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Does this block reference a known web-font origin (gstatic / Bunny / WP proxy)? |
| 110 |
* |
| 111 |
* @param string $text Block text. |
| 112 |
* @return bool |
| 113 |
*/ |
| 114 |
public function is_remote_origin( string $text ): bool { |
| 115 |
foreach ( array( 'fonts.gstatic.com', 'fonts.bunny.net', 'fonts.wp.com', 'fonts-api.wp.com' ) as $needle ) { |
| 116 |
if ( false !== stripos( $text, $needle ) ) { |
| 117 |
return true; |
| 118 |
} |
| 119 |
} |
| 120 |
|
| 121 |
return false; |
| 122 |
} |
| 123 |
|
| 124 |
/** |
| 125 |
* Detect variable fonts: the same src file used by 2+ @font-face blocks, |
| 126 |
* or a font-weight expressed as a range ("100 900"). |
| 127 |
* |
| 128 |
* @param array<int,array{full:string,body:string}> $blocks Parsed blocks. |
| 129 |
* @return array<string,bool> Map of lowercased family => true. |
| 130 |
*/ |
| 131 |
public function variable_families( array $blocks ): array { |
| 132 |
$src_count = array(); |
| 133 |
$variable = array(); |
| 134 |
|
| 135 |
foreach ( $blocks as $block ) { |
| 136 |
$props = $this->properties( $block['body'] ); |
| 137 |
$family = strtolower( $props['family'] ); |
| 138 |
|
| 139 |
if ( '' === $family ) { |
| 140 |
continue; |
| 141 |
} |
| 142 |
|
| 143 |
// Range weight => variable. |
| 144 |
if ( preg_match( '/\d+\s+\d+/', $props['weight'] ) ) { |
| 145 |
$variable[ $family ] = true; |
| 146 |
} |
| 147 |
|
| 148 |
if ( '' !== $props['src'] ) { |
| 149 |
$key = $family . '|' . $props['src']; |
| 150 |
$src_count[ $key ] = ( $src_count[ $key ] ?? 0 ) + 1; |
| 151 |
|
| 152 |
if ( $src_count[ $key ] > 1 ) { |
| 153 |
$variable[ $family ] = true; |
| 154 |
} |
| 155 |
} |
| 156 |
} |
| 157 |
|
| 158 |
return $variable; |
| 159 |
} |
| 160 |
|
| 161 |
/** |
| 162 |
* Build a normalized family-name comparator (case/space insensitive). |
| 163 |
* |
| 164 |
* @param string $family Family name. |
| 165 |
* @return string |
| 166 |
*/ |
| 167 |
public static function normalize_family( string $family ): string { |
| 168 |
return strtolower( trim( $family, " \t\n\r\0\x0B\"'" ) ); |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Infer the Google subset from a unicode-range declaration. |
| 173 |
* |
| 174 |
* Google's per-subset ranges are stable, so signature codepoints identify |
| 175 |
* the subset even when the `/* subset *\/` comment is stripped (minified or |
| 176 |
* inline CSS) — which is exactly why subset filtering failed for inline and |
| 177 |
* @import sources. |
| 178 |
* |
| 179 |
* @param string $range unicode-range value. |
| 180 |
* @return string Subset name, or '' if it can't be determined. |
| 181 |
*/ |
| 182 |
public static function subset_from_unicode_range( string $range ): string { |
| 183 |
$range = strtoupper( $range ); |
| 184 |
|
| 185 |
if ( '' === trim( $range ) ) { |
| 186 |
return ''; |
| 187 |
} |
| 188 |
|
| 189 |
// Signature codepoints per Google subset. Order matters: the most |
| 190 |
// specific / extended ranges are tested before their base script so an |
| 191 |
// "-ext" face is never misread as the base subset. This mirrors OMGF's |
| 192 |
// unicode-range → subset map and lets subset filtering work even when |
| 193 |
// the `/* subset */` comment is stripped (minified / inline / @import). |
| 194 |
$signatures = array( |
| 195 |
'vietnamese' => array( 'U+1EA0-1EF9', 'U+1EA0', 'U+1EF9', 'U+0102-0103', 'U+0300-0301', 'U+0303-0304', 'U+0309', 'U+0323' ), |
| 196 |
'cyrillic-ext' => array( 'U+0460-052F', 'U+0460', 'U+1C80', 'U+1C88', 'U+2DE0', 'U+A640', 'U+A69F' ), |
| 197 |
'cyrillic' => array( 'U+0400-045F', 'U+0400', 'U+0401', 'U+0410', 'U+0450', 'U+0490-0491', 'U+04B0-04B1' ), |
| 198 |
'greek-ext' => array( 'U+1F00-1FFF', 'U+1F00' ), |
| 199 |
'greek' => array( 'U+0370-0377', 'U+0370', 'U+0384', 'U+0386', 'U+0388-038A', 'U+0391', 'U+03A9' ), |
| 200 |
'devanagari' => array( 'U+0900-097F', 'U+0900', 'U+0966-096F', 'U+1CD0-1CF9', 'U+200C-200D', 'U+A830-A839' ), |
| 201 |
'bengali' => array( 'U+0980-09FE', 'U+0980', 'U+0985', 'U+09E6-09EF' ), |
| 202 |
'gujarati' => array( 'U+0A80-0AFF', 'U+0A80', 'U+0AE6-0AEF' ), |
| 203 |
'gurmukhi' => array( 'U+0A00-0A76', 'U+0A01', 'U+0A66-0A6F' ), |
| 204 |
'tamil' => array( 'U+0B82-0BFA', 'U+0B82', 'U+0BE6-0BEF' ), |
| 205 |
'telugu' => array( 'U+0C00-0C7F', 'U+0C00', 'U+0C66-0C6F' ), |
| 206 |
'kannada' => array( 'U+0C80-0CF3', 'U+0C80', 'U+0CE6-0CEF' ), |
| 207 |
'malayalam' => array( 'U+0D00-0D7F', 'U+0D00', 'U+0D66-0D6F' ), |
| 208 |
'sinhala' => array( 'U+0D81-0DF4', 'U+0D82', 'U+0DE6-0DEF' ), |
| 209 |
'oriya' => array( 'U+0B01-0B77', 'U+0B01', 'U+0B66-0B6F' ), |
| 210 |
'thai' => array( 'U+0E01-0E5B', 'U+0E01', 'U+0E50-0E59' ), |
| 211 |
'khmer' => array( 'U+1780-17FF', 'U+1780', 'U+17E0-17E9' ), |
| 212 |
'myanmar' => array( 'U+1000-109F', 'U+1000', 'U+1040-1049' ), |
| 213 |
'tibetan' => array( 'U+0F00-0FFF', 'U+0F00', 'U+0F20-0F29' ), |
| 214 |
'hebrew' => array( 'U+0590-05FF', 'U+0591-05F4', 'U+05D0-05EA', 'U+FB1D-FB4F' ), |
| 215 |
'arabic' => array( 'U+0600-06FF', 'U+0600', 'U+0750-077F', 'U+0870-088E', 'U+FB50-FDFF', 'U+FE70-FEFF' ), |
| 216 |
// CJK families ship many numbered ranges; a Hangul signature is the |
| 217 |
// one reliable named CJK marker. Chinese/Japanese arrive as numbered |
| 218 |
// subsets ('[0]') which stay unfiltered (kept) by the caller. |
| 219 |
'korean' => array( 'U+AC00-D7A3', 'U+1100-11FF', 'U+3130-318F', 'U+A960-A97F', 'U+D7B0-D7FF' ), |
| 220 |
'latin-ext' => array( 'U+0100-02AF', 'U+0100-024F', 'U+1E00-1EFF', 'U+0100', 'U+1E00', 'U+2C60-2C7F', 'U+A720-A7FF' ), |
| 221 |
'latin' => array( 'U+0000-00FF', 'U+0001-000F', 'U+0131', 'U+0152-0153', 'U+2000-206F' ), |
| 222 |
); |
| 223 |
|
| 224 |
foreach ( $signatures as $subset => $needles ) { |
| 225 |
foreach ( $needles as $needle ) { |
| 226 |
if ( false !== strpos( $range, $needle ) ) { |
| 227 |
return $subset; |
| 228 |
} |
| 229 |
} |
| 230 |
} |
| 231 |
|
| 232 |
// Basic-Latin range present and nothing else matched → latin. |
| 233 |
if ( false !== strpos( $range, 'U+00' ) ) { |
| 234 |
return 'latin'; |
| 235 |
} |
| 236 |
|
| 237 |
return ''; |
| 238 |
} |
| 239 |
|
| 240 |
/** |
| 241 |
* Extract a single property value from a declaration body. |
| 242 |
* |
| 243 |
* @param string $body Declaration body. |
| 244 |
* @param string $prop Property name. |
| 245 |
* @param string $fallback Default value. |
| 246 |
* @param bool $unquote Strip surrounding quotes (for family names). |
| 247 |
* @return string |
| 248 |
*/ |
| 249 |
private function prop_value( string $body, string $prop, string $fallback, bool $unquote = false ): string { |
| 250 |
// Match `prop : value ;` (or end of block). Property names are literal. |
| 251 |
if ( ! preg_match( '/(?:^|[;{\s])' . preg_quote( $prop, '/' ) . '\s*:\s*([^;]+)/i', $body, $m ) ) { |
| 252 |
return $fallback; |
| 253 |
} |
| 254 |
|
| 255 |
$value = trim( $m[1] ); |
| 256 |
|
| 257 |
if ( $unquote ) { |
| 258 |
$value = trim( $value, " \t\n\r\0\x0B\"'" ); |
| 259 |
} |
| 260 |
|
| 261 |
return '' === $value ? $fallback : $value; |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* Pull the first url() out of a src declaration. |
| 266 |
* |
| 267 |
* @param string $body Declaration body. |
| 268 |
* @return string |
| 269 |
*/ |
| 270 |
private function first_src_url( string $body ): string { |
| 271 |
if ( ! preg_match( '/src\s*:\s*[^;]*?url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $body, $m ) ) { |
| 272 |
return ''; |
| 273 |
} |
| 274 |
|
| 275 |
return trim( $m[1] ); |
| 276 |
} |
| 277 |
} |
| 278 |
|