PluginProbe
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant / trunk
EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant vtrunk
2.0.3 2.0.2 2.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2 1.3 2.0.0
easyfonts / src / Parser / FontFaceParser.php

FontFaceParser.php in EasyFonts – Host Google Fonts Locally, Fast & Auto-Optimize, GDPR Compliant trunk, at src/Parser/FontFaceParser.php

278 lines 8.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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