PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.3
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.3
16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / vendor / wp-php-toolkit / data-liberation / CSS / class-cssprocessor.php
jetpack / vendor / wp-php-toolkit / data-liberation / CSS Last commit date
class-cssprocessor.php 6 days ago
class-cssprocessor.php
1877 lines
1 <?php
2
3 namespace WordPress\DataLiberation\CSS;
4
5 use function WordPress\Encoding\codepoint_to_utf8_bytes;
6 use function WordPress\Encoding\compat\_wp_scan_utf8;
7 use function WordPress\Encoding\utf8_ord;
8 use function WordPress\Encoding\wp_scrub_utf8;
9
10 /**
11 * Tokenizes CSS according to the CSS Syntax Level 3 specification.
12 *
13 * This class follows the algorithm in https://www.w3.org/TR/css-syntax-3/ and
14 * exposes a pull-based API so callers can stream over large stylesheets without
15 * allocating every token up front. Each call to next_token() advances the cursor
16 * and fills in metadata (type, value, raw slice, byte offsets) that you can read
17 * through the getter methods.
18 *
19 * ## Design choices
20 *
21 * ### On-the-fly normalization
22 *
23 * The CSS Spec requires the following normalization step:
24 *
25 * > Replace any U+000D CARRIAGE RETURN (CR) code points, U+000C FORM FEED (FF)
26 * > code points, or pairs of U+000D CARRIAGE RETURN (CR) followed by U+000A LINE
27 * > FEED (LF) in input by a single U+000A LINE FEED (LF) code point.
28 * > Replace any U+0000 NULL or surrogate code points in input with U+FFFD REPLACEMENT
29 * > CHARACTER (�).
30 *
31 * This processor delays normalization as much as possible. That keeps the raw byte
32 * positions intact for accurate rewrites while still letting consumers ask for a
33 * normalized token when they need one.
34 *
35 * ### No EOF token
36 *
37 * The EOF token is a CSS parsing concept, not CSS tokenization concept. Therefore,
38 * this processor does not produce it.
39 *
40 * ### UTF-8 handling
41 *
42 * Only UTF-8 strings are supported. Invalid sequences are replaced with U+FFFD (�)
43 * using the maximal subpart approach described in
44 * https://www.unicode.org/versions/Unicode9.0.0/ch03.pdf, section 3.9 Best Practices
45 * for Using U+FFFD.
46 *
47 * ## Usage
48 *
49 * Basic iteration:
50 *
51 * ```php
52 * $css = 'width: 10px;';
53 * $processor = CSSProcessor::create( $css );
54 * while ( $processor->next_token() ) {
55 * echo $processor->get_normalized_token();
56 * }
57 * // Outputs:
58 * // width: 10px;
59 * ```
60 *
61 * Rewriting a URL while keeping the rest of the stylesheet intact:
62 *
63 * ```php
64 * $css = 'background: url(old.jpg) center / cover;';
65 * $processor = CSSProcessor::create( $css );
66 * while ( $processor->next_token() ) {
67 * if ( CSSProcessor::TOKEN_URL === $processor->get_token_type() ) {
68 * $processor->set_value( 'uploads/new.jpg' );
69 * }
70 * }
71 * $result = $processor->get_updated_css();
72 * // background: url(uploads/new.jpg) center / cover;
73 * ```
74 *
75 * Gathering diagnostics with byte offsets:
76 *
77 * ```php
78 * $css = "color: red;\ncolor: re\nd;";
79 * $processor = CSSProcessor::create( $css );
80 * $bad_strings = array();
81 * while ( $processor->next_token() ) {
82 * if ( CSSProcessor::TOKEN_BAD_STRING === $processor->get_token_type() ) {
83 * $bad_strings[] = array(
84 * 'start' => $processor->get_token_start(),
85 * 'length' => $processor->get_token_length(),
86 * 'value' => $processor->get_unnormalized_token(),
87 * );
88 * }
89 * }
90 * ```
91 *
92 * @see https://www.w3.org/TR/css-syntax-3/#tokenization
93 */
94 class CSSProcessor {
95 /**
96 * Token type constants matching the CSS Syntax Level 3 specification.
97 *
98 * @see https://www.w3.org/TR/css-syntax-3/#tokenization
99 */
100 public const TOKEN_WHITESPACE = 'whitespace-token';
101 public const TOKEN_COMMENT = 'comment';
102 public const TOKEN_STRING = 'string-token';
103
104 /**
105 * BAD-STRING tokens occur when a string contains an unescaped newline.
106 *
107 * Valid strings: "hello", 'world', "line1\Aline2" (escaped newline)
108 * Invalid (produces bad-string): "hello
109 * world" (literal newline breaks the string)
110 *
111 * The processor stops at the newline and produces a bad-string token for error recovery.
112 *
113 * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-string-token
114 */
115 public const TOKEN_BAD_STRING = 'bad-string-token';
116 public const TOKEN_HASH = 'hash-token';
117 public const TOKEN_DELIM = 'delim-token';
118 public const TOKEN_NUMBER = 'number-token';
119 public const TOKEN_PERCENTAGE = 'percentage-token';
120 public const TOKEN_DIMENSION = 'dimension-token';
121 public const TOKEN_AT_KEYWORD = 'at-keyword-token';
122 public const TOKEN_COLON = 'colon-token';
123 public const TOKEN_SEMICOLON = 'semicolon-token';
124 public const TOKEN_COMMA = 'comma-token';
125 public const TOKEN_LEFT_PAREN = '(-token';
126 public const TOKEN_RIGHT_PAREN = ')-token';
127 public const TOKEN_LEFT_BRACKET = '[-token';
128 public const TOKEN_RIGHT_BRACKET = ']-token';
129 public const TOKEN_LEFT_BRACE = '{-token';
130 public const TOKEN_RIGHT_BRACE = '}-token';
131 public const TOKEN_FUNCTION = 'function-token';
132
133 /**
134 * URL tokens represent unquoted URLs in url() notation.
135 *
136 * Valid: url(image.jpg), url(https://example.com)
137 * Quoted URLs are parsed as url( + string-token + ), not url-token.
138 *
139 * @see https://www.w3.org/TR/css-syntax-3/#typedef-url-token
140 */
141 public const TOKEN_URL = 'url-token';
142
143 /**
144 * BAD-URL tokens occur when a URL contains invalid characters.
145 *
146 * Invalid characters: quotes ("), apostrophes ('), parentheses (()
147 * Example invalid: url(image(.jpg) or url(image".jpg)
148 *
149 * When detected, the processor consumes everything up to ) or EOF.
150 * This prevents the bad URL from breaking subsequent tokens.
151 *
152 * @see https://www.w3.org/TR/css-syntax-3/#typedef-bad-url-token
153 */
154 public const TOKEN_BAD_URL = 'bad-url-token';
155
156 /**
157 * Identifier tokens, such as `color`, `margin-top`, `red`,
158 * `inherit`, `--my-var`, `\escaped`, `über` (Unicode), etc.
159 *
160 * They can contain: letters, digits, hyphens, underscores, non-ASCII, escapes
161 * and cannot start with a digit (unless preceded by a hyphen).
162 *
163 * @see https://www.w3.org/TR/css-syntax-3/#typedef-ident-token
164 */
165 public const TOKEN_IDENT = 'ident-token';
166
167 /**
168 * CDC (Comment Delimiter Close) token: -->
169 *
170 * Legacy token from when CSS was embedded in HTML <style> tags
171 * and needed to be hidden from old browsers using HTML comments:
172 *
173 * <style>
174 * <!--
175 * body { color: red; }
176 * -->
177 * </style>
178 *
179 * Modern CSS no longer needs these, but they're preserved for compatibility.
180 * In stylesheets, they're typically treated like whitespace.
181 *
182 * @see https://www.w3.org/TR/css-syntax-3/#typedef-CDC-token
183 */
184 public const TOKEN_CDC = 'CDC-token';
185
186 /**
187 * CDO (Comment Delimiter Open) token: <!--
188 *
189 * Legacy token from when CSS was embedded in HTML <style> tags.
190 * See TOKEN_CDC for full explanation of HTML comment compatibility.
191 *
192 * @see https://www.w3.org/TR/css-syntax-3/#typedef-CDO-token
193 */
194 public const TOKEN_CDO = 'CDO-token';
195
196 /**
197 * @var string
198 */
199 private $css;
200
201 /**
202 * @var int
203 */
204 private $length = 0;
205
206 /**
207 * @var int
208 */
209 private $at = 0;
210
211 /**
212 * The type of the current token. One of the self::TOKEN_* constants.
213 *
214 * @var string|null
215 */
216 private $token_type = null;
217
218 /**
219 * The byte offset at which the current token starts.
220 *
221 * Example:
222 *
223 * background-image: url(https://example.com/image.jpg);
224 * ^ token_starts_at
225 *
226 * @var int|null
227 */
228 private $token_starts_at = null;
229
230 /**
231 * The byte length of the current token.
232 *
233 * Example:
234 *
235 * background-image: url(https://example.com/image.jpg);
236 * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
237 * token_length
238 *
239 * @var int|null
240 */
241 private $token_length = null;
242
243 /**
244 * The byte offset at which the value of the current token starts.
245 *
246 * It is used for STRING and URL tokens. For example:
247 *
248 * background-image: url(https://example.com/image.jpg);
249 * ^ token_value_starts_at
250 *
251 * @var int|null
252 */
253 private $token_value_starts_at = null;
254
255 /**
256 * The byte offset at which the value of the current token starts.
257 *
258 * It is relevant for STRING and URL tokens. For example:
259 *
260 * background-image: url(https://example.com/image.jpg);
261 * ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
262 * token_value_length
263 *
264 * @var int|null
265 */
266 private $token_value_length = null;
267
268 /**
269 * The string value of the current token.
270 *
271 * For numbers, this is a float.
272 * For identifiers/functions/strings/URLs with escapes, this is a decoded string.
273 * Otherwise, it's null and the value is computed from token indices.
274 *
275 * @var string|float|null
276 */
277 private $token_value = null;
278
279 /**
280 * The unit of the current token, e.g. "px", "em", "deg", etc.
281 *
282 * @var string|null
283 */
284 private $token_unit = null;
285
286 /**
287 * Lexical replacements to apply to input CSS document.
288 *
289 * Tracks modifications to be applied to the CSS, such as changing URL values.
290 * Each entry is an associative array with 'start', 'length', and 'text' keys.
291 *
292 * @var array[]
293 */
294 private $lexical_updates = array();
295
296 /**
297 * Constructor for the CSS processor.
298 *
299 * Do not instantiate directly. Use CSSProcessor::create() instead.
300 *
301 * @param string $css CSS source to tokenize.
302 */
303 private function __construct( string $css ) {
304 $this->css = $css;
305 $this->length = strlen( $css );
306 }
307
308 /**
309 * Creates a CSS processor for the given CSS string.
310 *
311 * Use this method to create a CSS processor instance.
312 *
313 * ## Current Support
314 *
315 * - The only supported document encoding is `UTF-8`, which is the default value.
316 *
317 * @param string $css CSS source to tokenize.
318 * @param string $encoding Text encoding of the document; must be default of 'UTF-8'.
319 * @return static|null The created processor if successful, otherwise null.
320 */
321 public static function create( string $css, string $encoding = 'UTF-8' ) {
322 if ( 'UTF-8' !== $encoding ) {
323 return null;
324 }
325
326 return new static( $css );
327 }
328
329 /**
330 * Moves to the next token in the CSS stream.
331 *
332 * Implements the main tokenization loop, consuming the next token from the input stream.
333 *
334 * @see https://www.w3.org/TR/css-syntax-3/#consume-token
335 *
336 * @return bool Whether a token was found.
337 */
338 public function next_token(): bool {
339 $this->after_token();
340
341 // Bale out once we reach the end.
342 if ( $this->at >= $this->length ) {
343 return false;
344 }
345
346 /*
347 * CSS comments. They are not preserved as tokens in the specification, but we
348 * still track them.
349 *
350 * @see https://www.w3.org/TR/css-syntax-3/#consume-comment
351 */
352 if (
353 $this->at + 1 < $this->length &&
354 '/' === $this->css[ $this->at ] &&
355 '*' === $this->css[ $this->at + 1 ]
356 ) {
357 $this->token_type = self::TOKEN_COMMENT;
358 $this->token_starts_at = $this->at;
359 $this->token_value_starts_at = $this->at;
360
361 $end = strpos( $this->css, '*/', $this->at + 2 );
362 $this->at = false !== $end ? $end + 2 : $this->length;
363 $this->token_length = $this->at - $this->token_starts_at;
364 $this->token_value_length = $this->token_length - 4;
365 return true;
366 }
367
368 /*
369 * Whitespace tokens.
370 *
371 * We consider U+000A LINE FEED, U+0009 CHARACTER TABULATION, and U+0020 SPACE bytes covered by the spec.
372 * In addition, we also capture U+000D CARRIAGE RETURN and U+000C FORM FEED that are normally converted to
373 * U+000A LINE FEED during the preprocessing phase.
374 *
375 * @see https://www.w3.org/TR/css-syntax-3/#newline
376 * @see https://www.w3.org/TR/css-syntax-3/#whitespace
377 */
378 $whitespace_length = strspn( $this->css, "\t\n\f\r ", $this->at );
379 if ( $whitespace_length > 0 ) {
380 $this->token_type = self::TOKEN_WHITESPACE;
381 $this->token_length = $whitespace_length;
382 $this->token_starts_at = $this->at;
383 $this->at += $whitespace_length;
384 return true;
385 }
386
387 /*
388 * String tokens with either " or ' as delimiters.
389 *
390 * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token
391 */
392 if ( '"' === $this->css[ $this->at ] || "'" === $this->css[ $this->at ] ) {
393 return $this->consume_string();
394 }
395
396 $char = $this->css[ $this->at ];
397 $this->token_starts_at = $this->at;
398
399 /*
400 * U+0023 NUMBER SIGN (#)
401 *
402 * A hash token is created when # is followed by an ident code point or valid escape.
403 * This is commonly used for hex colors (#fff) or ID selectors (#header).
404 *
405 * @see https://www.w3.org/TR/css-syntax-3/#consume-token
406 */
407 if ( '#' === $char ) {
408 if ( $this->at + 1 < $this->length ) {
409 if (
410 $this->consume_ident_codepoint( $this->at + 1 ) > 0 ||
411 // The next two input code points are a valid escape.
412 $this->is_valid_escape( $this->at + 1 )
413 ) {
414 // Create a <hash-token>.
415 ++$this->at;
416
417 // We skip this check as we don't track the type flag:
418 // > If the next 3 input code points would start an ident sequence,
419 // > set the <hash-token>'s type flag to "id".
420
421 // Consume an ident sequence, and set the <hash-token>'s value to the returned string.
422 $this->consume_ident_sequence();
423 $this->token_type = self::TOKEN_HASH;
424 $this->token_length = $this->at - $this->token_starts_at;
425 return true;
426 }
427 }
428 // Otherwise, return a <delim-token> with its value set to the current input code point.
429 ++$this->at;
430 $this->token_type = self::TOKEN_DELIM;
431 $this->token_length = 1;
432 return true;
433 }
434
435 /*
436 * Simple single-byte tokens
437 *
438 * These characters form their own tokens when encountered.
439 * Note: ( tokens here are not function tokens - those are handled
440 * in consume_ident_like() when ( follows an identifier.
441 *
442 * @see https://www.w3.org/TR/css-syntax-3/#tokenization
443 */
444 $simple = array(
445 '(' => self::TOKEN_LEFT_PAREN,
446 ')' => self::TOKEN_RIGHT_PAREN,
447 ',' => self::TOKEN_COMMA,
448 ':' => self::TOKEN_COLON,
449 ';' => self::TOKEN_SEMICOLON,
450 '[' => self::TOKEN_LEFT_BRACKET,
451 ']' => self::TOKEN_RIGHT_BRACKET,
452 '{' => self::TOKEN_LEFT_BRACE,
453 '}' => self::TOKEN_RIGHT_BRACE,
454 );
455 if ( isset( $simple[ $char ] ) ) {
456 ++$this->at;
457 $this->token_type = $simple[ $char ];
458 $this->token_length = 1;
459 return true;
460 }
461
462 /*
463 * U+0040 COMMERCIAL AT (@)
464 *
465 * An at-keyword is @ followed by an identifier, used for at-rules like
466 * @media, @import, @keyframes, etc.
467 *
468 * @see https://www.w3.org/TR/css-syntax-3/#consume-token
469 */
470 if ( '@' === $char ) {
471 ++$this->at;
472 // If the next 3 input code points after the @ would start an ident sequence,
473 // consume an ident sequence, create an <at-keyword-token> with its value set to the returned value,
474 // and return it.
475 if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) {
476 $this->consume_ident_sequence();
477 $this->token_type = self::TOKEN_AT_KEYWORD;
478 $this->token_length = $this->at - $this->token_starts_at;
479 return true;
480 } else {
481 // Otherwise, return a <delim-token> with its value set to the current input code point.
482 $this->token_type = self::TOKEN_DELIM;
483 $this->token_length = 1;
484 return true;
485 }
486 }
487
488 /*
489 * Numbers start with digits, the plus sign, minus sign, and decimal point.
490 *
491 * @see https://www.w3.org/TR/css-syntax-3/#starts-with-a-number
492 */
493 if ( $this->would_next_3_code_points_start_a_number() ) {
494 return $this->consume_numeric();
495 }
496
497 /*
498 * U+002D HYPHEN-MINUS (-)
499 */
500 if ( '-' === $char ) {
501 // This case is covered above:
502 // > If the input stream starts with a number.
503
504 /*
505 * If followed by another hyphen and >, this is a CDC token (-->)
506 *
507 * Comment Delimiter Close - legacy HTML comment syntax in CSS.
508 *
509 * @see https://www.w3.org/TR/css-syntax-3/#CDC-token-diagram
510 */
511 if (
512 $this->at + 2 < $this->length &&
513 '-' === $this->css[ $this->at + 1 ] &&
514 '>' === $this->css[ $this->at + 2 ]
515 ) {
516 // Consume them and return a <CDC-token>.
517 $this->at += 3;
518 $this->token_type = self::TOKEN_CDC;
519 $this->token_length = 3;
520 return true;
521 }
522
523 // Otherwise, if the input stream starts with an ident sequence,
524 // reconsume the current input code point, consume an ident-like
525 // token, and return it.
526 if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) {
527 return $this->consume_ident_like();
528 }
529
530 // Otherwise, return a <delim-token> with its value set to the current input code point.
531 ++$this->at;
532 $this->token_type = self::TOKEN_DELIM;
533 $this->token_length = 1;
534 return true;
535 }
536
537 /*
538 * U+003C LESS-THAN SIGN (<)
539 * If followed by !--, this is a CDO token (<!--)
540 *
541 * Comment Delimiter Open - legacy HTML comment syntax in CSS.
542 *
543 * @see https://www.w3.org/TR/css-syntax-3/#CDO-token-diagram
544 */
545 if ( '<' === $char && $this->at + 3 < $this->length &&
546 '!' === $this->css[ $this->at + 1 ] &&
547 '-' === $this->css[ $this->at + 2 ] &&
548 '-' === $this->css[ $this->at + 3 ] ) {
549 // Consume them and return a <CDO-token>.
550 $this->at += 4;
551 $this->token_type = self::TOKEN_CDO;
552 $this->token_length = 4;
553 return true;
554 }
555
556 /*
557 * Ident-start code point
558 *
559 * If the input stream starts with an ident sequence, reconsume the current
560 * input code point, consume an ident-like token, and return it.
561 *
562 * Could be an identifier, function, or url() token.
563 *
564 * @see https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
565 */
566 if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) {
567 return $this->consume_ident_like();
568 }
569
570 /*
571 * Delim token (delimiter)
572 *
573 * Any code point that doesn't match above rules becomes a delim token.
574 * Handle multi-byte UTF-8 characters properly.
575 *
576 * @see https://www.w3.org/TR/css-syntax-3/#delim-token-diagram
577 */
578 if ( ord( $char ) >= 0x80 ) {
579 $new_at = $this->at;
580 $invalid_length = 0;
581 if ( 1 !== _wp_scan_utf8( $this->css, $new_at, $invalid_length, null, 1 ) ) {
582 /**
583 * Trouble ahead!
584 * Bytes at $at are not a valid UTF-8 sequence.
585 *
586 * We'll move forward by $invalid_length bytes and continue processing.
587 * Later on, during the string decoding, we'll replace the invalid bytes with U+FFFD
588 * via maximal subpart”replacement.
589 */
590 $matched_bytes = $invalid_length;
591 } else {
592 $matched_bytes = $new_at - $this->at;
593 }
594
595 $this->at += $matched_bytes;
596 $this->token_type = self::TOKEN_DELIM;
597 $this->token_length = $matched_bytes;
598 return true;
599 }
600
601 // Single ASCII delim.
602 ++$this->at;
603 $this->token_type = self::TOKEN_DELIM;
604 $this->token_length = 1;
605 return true;
606 }
607
608 /**
609 * Gets the current token type.
610 *
611 * @return string|null
612 */
613 public function get_token_type(): ?string {
614 return $this->token_type;
615 }
616
617 /**
618 * Gets the normalized token text from the CSS source.
619 *
620 * Returns the token with CSS normalization and escape decoding applied:
621 * - CSS escapes decoded (e.g., \6c → l, \2f → /, \A → newline)
622 * - \r\n, \r, \f → \n
623 * - \x00 → U+FFFD (�)
624 *
625 * This is different from get_token_value() which returns the semantic value
626 * (e.g., for strings: content without quotes; for numbers: numeric value).
627 *
628 * @return string|null
629 */
630 public function get_normalized_token(): ?string {
631 if ( null === $this->token_starts_at || null === $this->token_length ) {
632 return null;
633 }
634
635 return $this->decode_range(
636 $this->token_starts_at,
637 $this->token_length,
638 self::TOKEN_STRING === $this->token_type
639 );
640 }
641
642 /**
643 * Gets the raw, unnormalized token text from the CSS source.
644 *
645 * Returns the exact bytes from the source without any normalization.
646 * This preserves original line endings (\r\n, \r, \f) and null bytes.
647 *
648 * @return string|null
649 */
650 public function get_unnormalized_token(): ?string {
651 if ( null === $this->token_starts_at || null === $this->token_length ) {
652 return null;
653 }
654 return substr( $this->css, $this->token_starts_at, $this->token_length );
655 }
656
657 /**
658 * Gets the current token value as a normalized and decoded string. This is
659 * a slight divergence from the CSS Syntax Level 3 spec, where all the numberic
660 * values are parsed as numbers. This processor is only concerned with their
661 * textual representation.
662 *
663 * Returns the semantic value of the token per CSS Syntax Level 3 spec:
664 *
665 * - For delimiters: the single code point
666 * - For numbers/percentages: the string representation of the number
667 * - For dimensions: the string representation of the number (use get_token_unit() for the unit)
668 * - For identifiers/functions/hash/at-keywords: the decoded identifier string
669 * - For strings/URLs: the decoded string value
670 * - For other tokens: null
671 *
672 * @see https://www.w3.org/TR/css-syntax-3/#tokenization
673 * @return string|null
674 */
675 public function get_token_value() {
676 if ( null === $this->token_value ) {
677 if ( null === $this->token_starts_at || null === $this->token_length ) {
678 return null;
679 }
680
681 switch ( $this->token_type ) {
682 case self::TOKEN_HASH:
683 // Hash value starts after the # character.
684 $this->token_value = $this->decode_range( $this->token_starts_at + 1, $this->token_length - 1 );
685 break;
686
687 case self::TOKEN_AT_KEYWORD:
688 // At-keyword value starts after the @ character.
689 $this->token_value = $this->decode_range( $this->token_starts_at + 1, $this->token_length - 1 );
690 break;
691
692 case self::TOKEN_FUNCTION:
693 // Function name is everything except the final (.
694 $this->token_value = $this->decode_range( $this->token_starts_at, $this->token_length - 1 );
695 break;
696
697 case self::TOKEN_IDENT:
698 // Identifier is the entire token.
699 $this->token_value = $this->decode_range( $this->token_starts_at, $this->token_length );
700 break;
701
702 case self::TOKEN_STRING:
703 if ( null !== $this->token_value_starts_at && null !== $this->token_value_length ) {
704 $this->token_value = $this->decode_range(
705 $this->token_value_starts_at,
706 $this->token_value_length,
707 true
708 );
709 } else {
710 $this->token_value = null;
711 }
712 break;
713
714 case self::TOKEN_URL:
715 if ( null !== $this->token_value_starts_at && null !== $this->token_value_length ) {
716 $this->token_value = $this->decode_range(
717 $this->token_value_starts_at,
718 $this->token_value_length
719 );
720 } else {
721 $this->token_value = null;
722 }
723 break;
724
725 case self::TOKEN_DELIM:
726 // Delim value is the single code point.
727 $this->token_value = $this->decode_range( $this->token_starts_at, $this->token_length );
728 break;
729
730 case self::TOKEN_NUMBER:
731 // Return the string representation of the number (not parsed to float).
732 $this->token_value = substr( $this->css, $this->token_starts_at, $this->token_length );
733 break;
734
735 case self::TOKEN_PERCENTAGE:
736 // Return the string representation of the number (without the %).
737 $this->token_value = substr( $this->css, $this->token_starts_at, $this->token_length - 1 );
738 break;
739
740 case self::TOKEN_DIMENSION:
741 // Return the string representation of the number (without the unit).
742 $this->token_value = substr( $this->get_normalized_token(), 0, -strlen( $this->token_unit ) );
743 break;
744
745 default:
746 $this->token_value = null;
747 break;
748 }
749 }
750
751 return $this->token_value;
752 }
753
754 /**
755 * Determines whether the current token is a data URI.
756 *
757 * Only meaningful for URL and STRING tokens. Returns false for all other token types.
758 *
759 * @return bool Whether the current token value starts with "data:" (case-insensitive).
760 */
761 public function is_data_uri(): bool {
762 if ( null === $this->token_value_starts_at || null === $this->token_value_length ) {
763 return false;
764 }
765
766 if ( $this->token_value_length < 5 ) {
767 return false;
768 }
769
770 $offset = $this->token_value_starts_at;
771 return (
772 ( 'd' === $this->css[ $offset ] || 'D' === $this->css[ $offset ] ) &&
773 ( 'a' === $this->css[ $offset + 1 ] || 'A' === $this->css[ $offset + 1 ] ) &&
774 ( 't' === $this->css[ $offset + 2 ] || 'T' === $this->css[ $offset + 2 ] ) &&
775 ( 'a' === $this->css[ $offset + 3 ] || 'A' === $this->css[ $offset + 3 ] ) &&
776 ':' === $this->css[ $offset + 4 ]
777 );
778 }
779
780 /**
781 * Gets the token start at.
782 *
783 * @return int|null
784 */
785 public function get_token_start(): ?int {
786 return $this->token_starts_at;
787 }
788
789 /**
790 * Gets the token length.
791 *
792 * @return int|null
793 */
794 public function get_token_length(): ?int {
795 return $this->token_length;
796 }
797
798 /**
799 * Gets the unit for dimension tokens.
800 *
801 * @return string|null
802 */
803 public function get_token_unit(): ?string {
804 return $this->token_unit;
805 }
806
807 /**
808 * Gets the byte at where the token value starts (for STRING and URL tokens).
809 *
810 * @return int|null
811 */
812 public function get_token_value_start(): ?int {
813 return $this->token_value_starts_at;
814 }
815
816 /**
817 * Gets the byte length of the token value (for STRING and URL tokens).
818 *
819 * @return int|null
820 */
821 public function get_token_value_length(): ?int {
822 return $this->token_value_length;
823 }
824
825 /**
826 * Sets the value of the current URL token.
827 *
828 * This method allows modifying the URL value in url() tokens. The new value
829 * will be properly escaped according to CSS URL syntax rules.
830 *
831 * Currently only URL tokens are supported. Attempting to set the value on
832 * other token types will return false.
833 *
834 * Example:
835 *
836 * $css = 'background: url(old.jpg);';
837 * $processor = CSSProcessor::create( $css );
838 * while ( $processor->next_token() ) {
839 * if ( CSSProcessor::TOKEN_URL === $processor->get_token_type() ) {
840 * $processor->set_token_value( 'new.jpg' );
841 * }
842 * }
843 * echo $processor->get_updated_css();
844 * // Outputs: background: url(new.jpg);
845 *
846 * @param string $new_value The new URL value (should not include url() wrapper).
847 * @return bool Whether the value was successfully updated.
848 */
849 public function set_token_value( string $new_value ): bool {
850 // Only URL and string tokens are currently supported.
851 switch ( $this->token_type ) {
852 case self::TOKEN_URL:
853 $this->lexical_updates[] = array(
854 'start' => $this->token_value_starts_at,
855 'length' => $this->token_value_length,
856 'text' => $this->escape_url_value( $new_value ),
857 );
858 return true;
859 case self::TOKEN_STRING:
860 $this->lexical_updates[] = array(
861 'start' => $this->token_starts_at,
862 'length' => $this->token_length,
863 'text' => $this->escape_url_value( $new_value ),
864 );
865 return true;
866 default:
867 _doing_it_wrong( __METHOD__, 'set_token_value() only supports URL and string tokens. Got token type: ' . $this->token_type, '1.0.0' );
868 return false;
869 }
870 }
871
872 /**
873 * Escapes a URL value for use in quoted url() syntax.
874 *
875 * Always returns a quoted URL string since they're easier
876 * to escape. Quoted URLs are consumed using the string token
877 * rules, and the only values we need to escape in strings, are:
878 *
879 * * Trailing quote.
880 * * Newlines. That amounts to \n, \r, \f, \r\n when preprocessing is considered.
881 * * U+005C REVERSE SOLIDUS (\)
882 *
883 * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token
884 */
885 private function escape_url_value( string $unescaped ): string {
886 $escaped = '';
887 $at = 0;
888 while ( $at < strlen( $unescaped ) ) {
889 $safe_len = strcspn( $unescaped, "\n\r\f\\\"", $at );
890 if ( $safe_len > 0 ) {
891 $escaped .= substr( $unescaped, $at, $safe_len );
892 $at += $safe_len;
893 continue;
894 }
895
896 $unsafe_char = $unescaped[ $at ];
897 switch ( $unsafe_char ) {
898 case "\r":
899 ++$at;
900 /**
901 * Add a trailing space to prevent accidentally creating a
902 * wrong escape sequence. This is a valid CSS syntax and
903 * CSS parsers will ignore that whitespace.
904 *
905 * Without the space, "carriage\return" would be encoded as "carriage\aeturn",
906 * making `e` a part of the escape sequence `\ae` which is not
907 * what the caller intended.
908 */
909 $escaped .= '\\a ';
910 if ( strlen( $unescaped ) > $at + 1 && "\n" === $unescaped[ $at + 1 ] ) {
911 ++$at;
912 }
913 break;
914 case "\f":
915 case "\n":
916 ++$at;
917 $escaped .= '\\a ';
918 break;
919 case '\\':
920 ++$at;
921 $escaped .= '\\5C ';
922 break;
923 case '"':
924 ++$at;
925 $escaped .= '\\22 ';
926 break;
927 default:
928 _doing_it_wrong( __METHOD__, 'Unexpected character in URL value: ' . $unsafe_char, '1.0.0' );
929 break;
930 }
931 }
932 return '"' . $escaped . '"';
933 }
934
935 /**
936 * Returns the CSS with all modifications applied.
937 *
938 * This method applies all queued lexical updates and returns the modified CSS.
939 * If no modifications were made, returns the original CSS.
940 *
941 * Example:
942 *
943 * $css = 'background: url(old.jpg);';
944 * $processor = CSSProcessor::create( $css );
945 * while ( $processor->next_token() ) {
946 * if ( CSSProcessor::TOKEN_URL === $processor->get_token_type() ) {
947 * $processor->set_token_value( 'new.jpg' );
948 * }
949 * }
950 * echo $processor->get_updated_css();
951 * // Outputs: background: url(new.jpg);
952 *
953 * @return string The modified CSS.
954 */
955 public function get_updated_css(): string {
956 if ( empty( $this->lexical_updates ) ) {
957 return $this->css;
958 }
959
960 // Sort updates by start position in ascending order.
961 usort(
962 $this->lexical_updates,
963 function ( $a, $b ) {
964 return $a['start'] - $b['start'];
965 }
966 );
967
968 // Build the output by concatenating original CSS fragments with replacements.
969 $bytes_already_copied = 0;
970 $output = '';
971
972 foreach ( $this->lexical_updates as $update ) {
973 $output .= substr( $this->css, $bytes_already_copied, $update['start'] - $bytes_already_copied );
974 $output .= $update['text'];
975 $bytes_already_copied = $update['start'] + $update['length'];
976 }
977
978 // Copy remaining CSS after last update.
979 $output .= substr( $this->css, $bytes_already_copied );
980
981 return $output;
982 }
983
984 /**
985 * Clears token state between tokens.
986 */
987 private function after_token(): void {
988 $this->token_type = null;
989 $this->token_starts_at = null;
990 $this->token_length = null;
991 $this->token_value = null;
992 $this->token_unit = null;
993 $this->token_value_starts_at = null;
994 $this->token_value_length = null;
995 }
996
997 /**
998 * Consumes a string token.
999 *
1000 * Strings are quoted with either " or ' and can contain escape sequences.
1001 * Newlines inside strings (without escaping) make the string invalid.
1002 *
1003 * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token
1004 *
1005 * @return bool
1006 */
1007 private function consume_string(): bool {
1008 // Initially create a <string-token> with its value set to the empty string.
1009 $this->token_starts_at = $this->at;
1010 $ending_char = $this->css[ $this->at ];
1011
1012 // Skip past the opening quote.
1013 ++$this->at;
1014 $value_starts_at = $this->at;
1015
1016 // Characters that need special handling: the ending quote, newlines, backslashes.
1017 $special_chars = "'" === $ending_char ? "'\n\f\r\\" : "\"\n\f\r\\";
1018
1019 while ( $this->at < $this->length ) {
1020 // Consume normal characters until we hit a special character.
1021 $normal_len = strcspn( $this->css, $special_chars, $this->at );
1022 if ( $normal_len > 0 ) {
1023 $this->at += $normal_len;
1024 }
1025
1026 if ( $this->at >= $this->length ) {
1027 break; // EOF.
1028 }
1029
1030 $char = $this->css[ $this->at ];
1031 switch ( $char ) {
1032 case $ending_char:
1033 // Ending quote.
1034 // Return the <string-token>.
1035 ++$this->at;
1036 $this->token_type = self::TOKEN_STRING;
1037 $this->token_length = $this->at - $this->token_starts_at;
1038 $this->token_value_starts_at = $value_starts_at;
1039 $this->token_value_length = $this->at - $value_starts_at - 1;
1040 return true;
1041
1042 case "\n":
1043 case "\f":
1044 case "\r":
1045 /*
1046 * Newline.
1047 *
1048 * This is a parse error. Reconsume the current input code point,
1049 * create a <bad-string-token>, and return it.
1050 *
1051 * Unescaped newlines are not allowed in strings. To include a newline,
1052 * it must be escaped as \A or the string must end and a new one begin.
1053 *
1054 * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token
1055 */
1056 $this->token_type = self::TOKEN_BAD_STRING;
1057 $this->token_length = $this->at - $this->token_starts_at;
1058 $this->token_value_starts_at = $value_starts_at;
1059 $this->token_value_length = $this->at - $value_starts_at;
1060 return true;
1061
1062 case '\\':
1063 // U+005C REVERSE SOLIDUS (\)
1064 // If the next input code point is EOF, do nothing.
1065 ++$this->at;
1066 if ( $this->at >= $this->length ) {
1067 // Backslash-EOF: do nothing, just consume the backslash.
1068 continue 2;
1069 }
1070
1071 // Otherwise, if the next input code point is a newline, consume it.
1072 $next = $this->css[ $this->at ];
1073 if ( "\n" === $next || "\f" === $next ) {
1074 ++$this->at;
1075 continue 2;
1076 } elseif ( "\r" === $next ) {
1077 ++$this->at;
1078 // Handle \r\n as a single newline.
1079 if ( $this->at < $this->length && "\n" === $this->css[ $this->at ] ) {
1080 ++$this->at;
1081 }
1082 continue 2;
1083 }
1084
1085 // Otherwise, (the stream starts with a valid escape) consume an escaped
1086 // code point (just to advance position, don't store the result).
1087 $this->decode_escape_at( $this->at, $matched_bytes );
1088 $this->at += $matched_bytes;
1089 continue 2;
1090
1091 default:
1092 _doing_it_wrong( __METHOD__, 'Unexpected character in string: ' . $char, '1.0.0' );
1093 break;
1094 }
1095 }
1096
1097 // EOF
1098 // This is a parse error. Return the <string-token>.
1099 $this->token_type = self::TOKEN_STRING;
1100 $this->token_length = $this->at - $this->token_starts_at;
1101 $this->token_value_starts_at = $value_starts_at;
1102 $this->token_value_length = $this->at - $value_starts_at;
1103 return true;
1104 }
1105
1106 /**
1107 * Consumes a numeric token (number, percentage, dimension).
1108 *
1109 * Numbers can be integers or decimals, with optional sign and exponent.
1110 * They can be followed by % (percentage) or an identifier (dimension).
1111 *
1112 * @TODO: Keep track of the "type" flag ("integer" or "number").
1113 *
1114 * @see https://www.w3.org/TR/css-syntax-3/#consume-numeric-token
1115 * @see https://www.w3.org/TR/css-syntax-3/#consume-number
1116 *
1117 * @return bool
1118 */
1119 private function consume_numeric(): bool {
1120 // Consume a number and let number be the result.
1121
1122 // If the next input code point is U+002B PLUS SIGN (+) or U+002D HYPHEN-MINUS (-),
1123 // consume it and append it to repr.
1124 if ( '+' === $this->css[ $this->at ] || '-' === $this->css[ $this->at ] ) {
1125 ++$this->at;
1126 }
1127
1128 // While the next input code point is a digit, consume it and append it to repr.
1129 $digits = strspn( $this->css, '0123456789', $this->at );
1130 if ( $digits > 0 ) {
1131 $this->at += $digits;
1132 }
1133
1134 // If the next 2 input code points are U+002E FULL STOP (.) followed by a digit, then.
1135 if (
1136 $this->at + 1 < $this->length &&
1137 '.' === $this->css[ $this->at ] &&
1138 $this->css[ $this->at + 1 ] >= '0' &&
1139 $this->css[ $this->at + 1 ] <= '9'
1140 ) {
1141 // Consume them.
1142 ++$this->at;
1143 // While the next input code point is a digit, consume it and append it to repr.
1144 $digits = strspn( $this->css, '0123456789', $this->at );
1145 if ( $digits > 0 ) {
1146 $this->at += $digits;
1147 }
1148 }
1149
1150 // If the next 2 or 3 input code points are U+0045 LATIN CAPITAL LETTER E (E)
1151 // or U+0065 LATIN SMALL LETTER E (e), optionally followed by U+002D HYPHEN-MINUS (-)
1152 // or U+002B PLUS SIGN (+), followed by a digit, then.
1153 if ( $this->at < $this->length ) {
1154 $e = $this->css[ $this->at ];
1155 if ( 'e' === $e || 'E' === $e ) {
1156 $save_pos = $this->at;
1157 ++$this->at;
1158 $has_exp = false;
1159
1160 if ( $this->at < $this->length ) {
1161 $next = $this->css[ $this->at ];
1162 if ( ( '+' === $next || '-' === $next ) && $this->at + 1 < $this->length &&
1163 $this->css[ $this->at + 1 ] >= '0' && $this->css[ $this->at + 1 ] <= '9' ) {
1164 // Consume them.
1165 ++$this->at;
1166 $has_exp = true;
1167 } elseif ( $next >= '0' && $next <= '9' ) {
1168 $has_exp = true;
1169 }
1170 }
1171
1172 if ( $has_exp ) {
1173 // While the next input code point is a digit, consume it and append it to repr.
1174 $digits = strspn( $this->css, '0123456789', $this->at );
1175 if ( $digits > 0 ) {
1176 $this->at += $digits;
1177 }
1178 } else {
1179 $this->at = $save_pos;
1180 }
1181 }
1182 }
1183
1184 /**
1185 * This is the end of spec section 4.3.12. Consume a number.
1186 * We still have some work to do as specified in section 4.3.3. Consume a numeric token:
1187 * https://www.w3.org/TR/css-syntax-3/#consume-numeric-token
1188 */
1189
1190 // If the next 3 input code points would start an ident sequence, then.
1191 if ( $this->check_if_3_code_points_start_an_ident_sequence( $this->at ) ) {
1192 // Create a <dimension-token> with the same value and type flag as number,
1193 // and a unit set initially to the empty string.
1194 // Consume an ident sequence. Set the <dimension-token>'s unit to the returned value.
1195 $unit_starts_at = $this->at;
1196 $this->consume_ident_sequence();
1197 $this->token_unit = $this->decode_range( $unit_starts_at, $this->at - $unit_starts_at );
1198 $this->token_type = self::TOKEN_DIMENSION;
1199 $this->token_length = $this->at - $this->token_starts_at;
1200 return true;
1201 }
1202
1203 // Otherwise, if the next input code point is U+0025 PERCENTAGE SIGN (%), consume it.
1204 // Create a <percentage-token> with the same value as number, and return it.
1205 if ( $this->at < $this->length && '%' === $this->css[ $this->at ] ) {
1206 ++$this->at;
1207 $this->token_type = self::TOKEN_PERCENTAGE;
1208 $this->token_length = $this->at - $this->token_starts_at;
1209 return true;
1210 }
1211
1212 // Otherwise, create a <number-token> with the same value and type flag as number, and return it.
1213 $this->token_type = self::TOKEN_NUMBER;
1214 $this->token_length = $this->at - $this->token_starts_at;
1215 return true;
1216 }
1217
1218 /**
1219 * Consumes an ident-like token (function, url, ident).
1220 *
1221 * After consuming an identifier, checks if it's followed by '(' to determine
1222 * if it's a function or url() token, otherwise it's a plain identifier.
1223 *
1224 * @see https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
1225 *
1226 * @return bool
1227 */
1228 private function consume_ident_like(): bool {
1229 // Consume an ident sequence, and let string be the result.
1230 $ident_start = $this->at;
1231 $decoded = $this->consume_ident_sequence();
1232 $string = $decoded ?? $this->decode_range( $ident_start, $this->at - $ident_start );
1233
1234 // If string's value is an ASCII case-insensitive match for "url",
1235 // and the next input code point is U+0028 LEFT PARENTHESIS (().
1236 if ( 0 === strcasecmp( $string, 'url' ) && $this->at < $this->length && '(' === $this->css[ $this->at ] ) {
1237 // Consume it.
1238 ++$this->at;
1239
1240 // While the next two input code points are whitespace, consume the next input code point.
1241 $ws_len = strspn( $this->css, "\t\n\f\r ", $this->at );
1242
1243 // If the next one or two input code points are U+0022 QUOTATION MARK ("),
1244 // U+0027 APOSTROPHE ('), or whitespace followed by U+0022 QUOTATION MARK (")
1245 // or U+0027 APOSTROPHE (').
1246 if ( $this->at + $ws_len < $this->length ) {
1247 $next = $this->css[ $this->at + $ws_len ];
1248 if ( '"' === $next || "'" === $next ) {
1249 // then create a <function-token> with its value set to string and return it.
1250 if ( null !== $decoded ) {
1251 $this->token_value = $decoded;
1252 }
1253 $this->token_type = self::TOKEN_FUNCTION;
1254 $this->token_length = $this->at - $this->token_starts_at;
1255 return true;
1256 }
1257 }
1258
1259 // Otherwise, consume a url token, and return it.
1260 $this->at += $ws_len;
1261 return $this->consume_url();
1262 }
1263
1264 // Otherwise, if the next input code point is U+0028 LEFT PARENTHESIS (().
1265 if ( $this->at < $this->length && '(' === $this->css[ $this->at ] ) {
1266 // Consume it.
1267 ++$this->at;
1268 // Create a <function-token> with its value set to string and return it.
1269 if ( null !== $decoded ) {
1270 $this->token_value = $decoded;
1271 }
1272 $this->token_type = self::TOKEN_FUNCTION;
1273 $this->token_length = $this->at - $this->token_starts_at;
1274 return true;
1275 }
1276
1277 // Otherwise, create an <ident-token> with its value set to string and return it.
1278 if ( null !== $decoded ) {
1279 $this->token_value = $decoded;
1280 }
1281 $this->token_type = self::TOKEN_IDENT;
1282 $this->token_length = $this->at - $this->token_starts_at;
1283 return true;
1284 }
1285
1286 /**
1287 * Consumes a url token.
1288 *
1289 * URL tokens can contain unquoted URLs with escape sequences but not quotes,
1290 * parentheses, or certain control characters. Invalid characters create a
1291 * bad-url token.
1292 *
1293 * @see https://www.w3.org/TR/css-syntax-3/#consume-url-token
1294 *
1295 * @return bool
1296 */
1297 private function consume_url(): bool {
1298 // Initially create a <url-token> with its value set to the empty string.
1299 // Consume as much whitespace as possible.
1300 $this->at += strspn( $this->css, "\t\n\f\r ", $this->at );
1301
1302 $value_starts_at = $this->at;
1303
1304 // Repeatedly consume the next input code point from the stream.
1305 while ( $this->at < $this->length ) {
1306 // U+0029 RIGHT PARENTHESIS ())
1307 // Return the <url-token>.
1308 if ( ')' === $this->css[ $this->at ] ) {
1309 ++$this->at;
1310 $this->token_type = self::TOKEN_URL;
1311 $this->token_length = $this->at - $this->token_starts_at;
1312 $this->token_value_starts_at = $value_starts_at;
1313 $this->token_value_length = $this->at - $value_starts_at - 1;
1314 return true;
1315 }
1316
1317 // whitespace
1318 // Consume as much whitespace as possible. If the next input code point is
1319 // U+0029 RIGHT PARENTHESIS ()) or EOF, consume it and return the <url-token>
1320 // (if EOF was encountered, this is a parse error); otherwise, consume the
1321 // remnants of a bad url, create a <bad-url-token>, and return it.
1322 $ws_len = strspn( $this->css, "\t\n\f\r ", $this->at );
1323 if ( $ws_len > 0 ) {
1324 $value_ends_at = $this->at;
1325 $this->at += $ws_len;
1326 // Accept either ) or EOF after whitespace.
1327 if ( $this->at >= $this->length ) {
1328 // EOF is a parse error, but we return the <url-token> anyway.
1329 $this->token_type = self::TOKEN_URL;
1330 $this->token_length = $this->at - $this->token_starts_at;
1331 $this->token_value_starts_at = $value_starts_at;
1332 $this->token_value_length = $value_ends_at - $value_starts_at;
1333 return true;
1334 }
1335
1336 if ( ')' === $this->css[ $this->at ] ) {
1337 // Skip the closing parenthesis and return the <url-token>.
1338 ++$this->at;
1339 $this->token_type = self::TOKEN_URL;
1340 $this->token_length = $this->at - $this->token_starts_at;
1341 $this->token_value_starts_at = $value_starts_at;
1342 $this->token_value_length = $value_ends_at - $value_starts_at;
1343 return true;
1344 }
1345
1346 return $this->consume_remnants_of_bad_url();
1347 }
1348
1349 // These codepoints trigger a parse error.
1350 $byte = ord( $this->css[ $this->at ] );
1351 if (
1352 '"' === $this->css[ $this->at ] ||
1353 "'" === $this->css[ $this->at ] ||
1354 '(' === $this->css[ $this->at ] ||
1355
1356 // Non-printable code point.
1357 $byte <= 0x08 ||
1358
1359 // Line Tabulation.
1360 0x0B === $byte ||
1361
1362 // Control characters.
1363 ( $byte >= 0x000E && $byte <= 0x001F ) ||
1364
1365 // Delete.
1366 0x7F === $byte
1367 ) {
1368 // Consume the remnants of a bad url,
1369 // create a <bad-url-token>, and return it.
1370 return $this->consume_remnants_of_bad_url();
1371 }
1372
1373 // U+005C REVERSE SOLIDUS (\)
1374 // If the stream starts with a valid escape, consume an escaped code point.
1375 if ( '\\' === $this->css[ $this->at ] ) {
1376 if ( $this->is_valid_escape( $this->at ) ) {
1377 ++$this->at;
1378 $this->decode_escape_at( $this->at, $matched_bytes );
1379 $this->at += $matched_bytes;
1380 continue;
1381 }
1382 // Otherwise, this is a parse error. Consume the remnants of a bad url,
1383 // create a <bad-url-token>, and return it.
1384 return $this->consume_remnants_of_bad_url();
1385 }
1386
1387 $at = $this->at;
1388 $invalid_length = 0;
1389 if ( 1 !== _wp_scan_utf8( $this->css, $at, $invalid_length, null, 1 ) ) {
1390 /**
1391 * Trouble ahead!
1392 * Bytes at $at are not a valid UTF-8 sequence.
1393 *
1394 * We'll move forward by $invalid_length bytes and continue processing.
1395 * Later on, during the string decoding, we'll replace the invalid bytes with U+FFFD
1396 * via maximal subpart”replacement.
1397 */
1398 $this->at += $invalid_length;
1399 } else {
1400 $this->at = $at;
1401 }
1402 }
1403
1404 // EOF
1405 // This is a parse error. Return the <url-token>.
1406 $this->token_type = self::TOKEN_URL;
1407 $this->token_length = $this->at - $this->token_starts_at;
1408 $this->token_value_starts_at = $value_starts_at;
1409 $this->token_value_length = $this->at - $value_starts_at;
1410 return true;
1411 }
1412
1413 /**
1414 * Finishes a bad url token by consuming remnants.
1415 *
1416 * When an invalid character is encountered in a URL, we must consume
1417 * the remainder of the URL up to the closing ) or EOF.
1418 *
1419 * @see https://www.w3.org/TR/css-syntax-3/#consume-remnants-of-bad-url
1420 *
1421 * @return bool
1422 */
1423 private function consume_remnants_of_bad_url(): bool {
1424 while ( $this->at < $this->length ) {
1425 $this->at += strcspn( $this->css, ')\\', $this->at );
1426
1427 if ( $this->at >= $this->length ) {
1428 break;
1429 }
1430
1431 if ( '\\' === $this->css[ $this->at ] ) {
1432 ++$this->at;
1433 if ( $this->is_valid_escape( $this->at - 1 ) ) {
1434 $this->decode_escape_at( $this->at, $matched_bytes );
1435 $this->at += $matched_bytes;
1436 continue;
1437 }
1438 } elseif ( ')' === $this->css[ $this->at ] ) {
1439 ++$this->at;
1440 break;
1441 }
1442 }
1443
1444 $this->token_type = self::TOKEN_BAD_URL;
1445 $this->token_length = $this->at - $this->token_starts_at;
1446 return true;
1447 }
1448
1449 /**
1450 * Consumes an identifier sequence.
1451 *
1452 * Identifiers can contain letters, digits, hyphens, underscores, non-ASCII
1453 * characters, and escape sequences. Null bytes are replaced with U+FFFD.
1454 *
1455 * Returns the decoded identifier string if escapes were encountered,
1456 * or null if no decoding was needed (can use raw substring).
1457 *
1458 * @see https://www.w3.org/TR/css-syntax-3/#consume-name
1459 */
1460 private function consume_ident_sequence() {
1461 while ( $this->at < $this->length ) {
1462 $codepoint_bytes = $this->consume_ident_codepoint( $this->at );
1463 if ( $codepoint_bytes > 0 ) {
1464 $this->at += $codepoint_bytes;
1465 continue;
1466 }
1467
1468 if ( $this->is_valid_escape( $this->at ) ) {
1469 ++$this->at;
1470
1471 $this->decode_escape_at( $this->at, $matched_bytes );
1472 $this->at += $matched_bytes;
1473 continue;
1474 }
1475
1476 break;
1477 }
1478 }
1479
1480 /**
1481 * Ident-start code point
1482 * A letter, a non-ASCII code point, or U+005F LOW LINE (_).
1483 *
1484 * Ident code point
1485 * An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-).
1486 *
1487 * @see https://www.w3.org/TR/css-syntax-3/#ident-start-code-point
1488 * @return int The number of bytes consumed.
1489 */
1490 private function consume_ident_codepoint( $at ): int {
1491 // ident code points.
1492 if ( ( $this->css[ $at ] >= '0' && $this->css[ $at ] <= '9' ) ||
1493 '-' === $this->css[ $at ] ) {
1494 return 1;
1495 }
1496
1497 return $this->consume_ident_start_codepoint( $at );
1498 }
1499
1500
1501 /**
1502 * Ident-start code point
1503 * A letter, a non-ASCII code point, or U+005F LOW LINE (_).
1504 *
1505 * Ident code point
1506 * An ident-start code point, a digit, or U+002D HYPHEN-MINUS (-).
1507 *
1508 * @see https://www.w3.org/TR/css-syntax-3/#ident-start-code-point
1509 * @return int The number of bytes consumed.
1510 */
1511 private function consume_ident_start_codepoint( $at ): int {
1512 if ( $at >= $this->length ) {
1513 return 0;
1514 }
1515
1516 // ASCII codepoints.
1517 if ( ( $this->css[ $at ] >= 'A' && $this->css[ $at ] <= 'Z' ) ||
1518 ( $this->css[ $at ] >= 'a' && $this->css[ $at ] <= 'z' ) ||
1519 '_' === $this->css[ $at ] ) {
1520 return 1;
1521 }
1522
1523 // Special case for null bytes – they are replaced with U+FFFD during preprocessing.
1524 if ( "\x00" === $this->css[ $at ] ) {
1525 return 1;
1526 }
1527
1528 $new_at = $at;
1529 $invalid_length = 0;
1530 if ( 1 !== _wp_scan_utf8( $this->css, $new_at, $invalid_length, null, 1 ) ) {
1531 /**
1532 * Trouble ahead!
1533 * Bytes at $at are not a valid UTF-8 sequence.
1534 *
1535 * We'll move forward by $invalid_length bytes and continue processing.
1536 * Later on, during the string decoding, we'll replace the invalid bytes with U+FFFD
1537 * via maximal subpart”replacement.
1538 */
1539 return $invalid_length;
1540 }
1541
1542 $codepoint_byte_length = $new_at - $at;
1543 $codepoint = utf8_ord( substr( $this->css, $at, $codepoint_byte_length ) );
1544 if ( null !== $codepoint && $codepoint >= 0x80 ) {
1545 return $codepoint_byte_length;
1546 }
1547 return 0;
1548 }
1549
1550 /**
1551 * Decodes and normalizes ident-like or string CSS values from a byte range.
1552 *
1553 * For example:
1554 * ┌──────────────┬────────┐
1555 * │ Input │ Output │
1556 * ├──────────────┼────────┤
1557 * │ 'xyz' │ 'xyz' │
1558 * │ '\x\y\z' │ 'xyz' │
1559 * │ 'x\79z' │ 'xyz' │
1560 * │ 'x\000079 z' │ 'xyz' │
1561 * │ 'a\r\nb' │ 'a\nb' │
1562 * │ 'a\0b' │ 'a�b' │
1563 * └──────────────┴────────┘
1564 *
1565 * @param int $start Start byte offset.
1566 * @param int $length Length of the substring to decode.
1567 * @param bool $string_escapes Optional, default false. When true, apply additional escape
1568 * rules that apply only to string tokens.
1569 * @return string Decoded and normalized string.
1570 */
1571 private function decode_range( int $start, int $length, bool $string_escapes = false ): string {
1572 // Fast path: check if any processing is needed.
1573 $slice = wp_scrub_utf8( substr( $this->css, $start, $length ) );
1574 $special_chars = "\\\r\f\x00";
1575 if ( false === strpbrk( $slice, $special_chars ) ) {
1576 // No special chars - return raw substring (almost zero allocations).
1577 return $slice;
1578 }
1579
1580 // Slow path: build decoded string (one allocation).
1581 $decoded = '';
1582 $at = $start;
1583 $end = $start + $length;
1584
1585 while ( $at < $end ) {
1586 // Find next special character.
1587 $normal_len = strcspn( $this->css, $special_chars, $at );
1588 if ( $normal_len > 0 ) {
1589 // Clamp to not exceed the end boundary.
1590 $normal_len = min( $normal_len, $end - $at );
1591 $decoded .= substr( $this->css, $at, $normal_len );
1592 $at += $normal_len;
1593 }
1594
1595 if ( $at >= $end ) {
1596 break;
1597 }
1598
1599 $char = $this->css[ $at ];
1600
1601 // Handle escapes.
1602 if ( '\\' === $char ) {
1603 /**
1604 * String tokens have special escape rules:
1605 * - 0x5C (backslash) at EOF: consume the backslash, produce no value.
1606 * - 0x5C (backslash) followed by 0x0A (LF), 0x0C (FF), or 0x0D (CR):
1607 * consume both characters as a line continuation, produce no value.
1608 * - 0x5C (backslash) followed by 0x0D 0x0A (CRLF):
1609 * consume all three characters as a line continuation, produce no value.
1610 * These must be checked before the general escape path.
1611 *
1612 * @see https://www.w3.org/TR/css-syntax-3/#consume-string-token
1613 */
1614 if ( $string_escapes ) {
1615 if ( $at + 1 >= $end ) {
1616 // 0x5C at EOF: consume the backslash and stop.
1617 ++$at;
1618 continue;
1619 }
1620 $next = $this->css[ $at + 1 ];
1621 if ( "\n" === $next || "\f" === $next ) {
1622 // 0x5C followed by 0x0A (LF) or 0x0C (FF): line continuation.
1623 $at += 2;
1624 continue;
1625 }
1626 if ( "\r" === $next ) {
1627 // 0x5C followed by 0x0D (CR): line continuation; 0x0D 0x0A counts as one newline.
1628 $at += 2;
1629 if ( $at < $end && "\n" === $this->css[ $at ] ) {
1630 ++$at;
1631 }
1632 continue;
1633 }
1634 }
1635
1636 if ( $this->is_valid_escape( $at ) ) {
1637 ++$at;
1638 $decoded .= $this->decode_escape_at( $at, $bytes_consumed );
1639 $at += $bytes_consumed;
1640 continue;
1641 }
1642 // Invalid escape - consume the backslash and keep going.
1643 $decoded .= '\\';
1644 ++$at;
1645 continue;
1646 }
1647
1648 // CSS normalization: \r\n, \r, and \f all become \n.
1649 if ( "\r" === $char ) {
1650 $decoded .= "\n";
1651 ++$at;
1652 // Handle \r\n as single newline.
1653 if ( $at < $end && "\n" === $this->css[ $at ] ) {
1654 ++$at;
1655 }
1656 continue;
1657 }
1658
1659 if ( "\f" === $char ) {
1660 $decoded .= "\n";
1661 ++$at;
1662 continue;
1663 }
1664
1665 // Null bytes become U+FFFD.
1666 if ( "\x00" === $char ) {
1667 $decoded .= "\u{FFFD}";
1668 ++$at;
1669 continue;
1670 }
1671 }
1672
1673 return $decoded;
1674 }
1675
1676 /**
1677 * Decodes an escape sequence starting at the given offset without
1678 * modifying $this->at.
1679 *
1680 * Escape sequences are backslash followed by 1-6 hex digits (with optional
1681 * trailing whitespace) or any other character. Invalid code points are
1682 * replaced with U+FFFD.
1683 *
1684 * @see https://www.w3.org/TR/css-syntax-3/#consume-escaped-code-point
1685 *
1686 * @param int $offset Byte offset (should point to the character after the backslash).
1687 * @param int &$bytes_consumed Output parameter: number of bytes consumed.
1688 * @return string The decoded character(s).
1689 */
1690 private function decode_escape_at( int $offset, &$bytes_consumed ): string {
1691 // This method assumes the U+005C REVERSE SOLIDUS (\) has already been consumed
1692 // and the next input code point has already been verified to be part of a valid
1693 // escape sequence.
1694 $at = $offset;
1695
1696 // EOF.
1697 if ( $at >= $this->length ) {
1698 // This is a parse error. Return U+FFFD REPLACEMENT CHARACTER (�).
1699 $bytes_consumed = 0;
1700 return "\u{FFFD}";
1701 }
1702
1703 // Hex digits.
1704 $hex_len = strspn( $this->css, '0123456789ABCDEFabcdef', $at );
1705 if ( $hex_len > 0 ) {
1706 // Consume up to 6 hex digits.
1707 $hex_len = min( $hex_len, 6 );
1708 $hex = substr( $this->css, $at, $hex_len );
1709 $at += $hex_len;
1710
1711 // If the next input code point is whitespace, consume it as well.
1712 if ( $at < $this->length ) {
1713 $next = $this->css[ $at ];
1714 if ( "\t" === $next || "\n" === $next || "\f" === $next || ' ' === $next ) {
1715 ++$at;
1716 } elseif ( "\r" === $next ) {
1717 ++$at;
1718 // Handle \r\n as a single whitespace – the preprocessing phase would replace \r\n with \n.
1719 if ( $at < $this->length && "\n" === $this->css[ $at ] ) {
1720 ++$at;
1721 }
1722 }
1723 }
1724
1725 $bytes_consumed = $at - $offset;
1726 // Convert the hex digits to a UTF-8 string.
1727 return codepoint_to_utf8_bytes( hexdec( $hex ) );
1728 }
1729
1730 // Anything else.
1731 // Return the current input code point.
1732 // Null bytes are replaced with U+FFFD during preprocessing.
1733 if ( "\x00" === $this->css[ $at ] ) {
1734 $bytes_consumed = 1;
1735 return "\u{FFFD}";
1736 }
1737
1738 $new_at = $at;
1739 $invalid_length = 0;
1740 if ( 1 !== _wp_scan_utf8( $this->css, $new_at, $invalid_length, null, 1 ) ) {
1741 /**
1742 * Trouble ahead!
1743 * Bytes at $at are not a valid UTF-8 sequence.
1744 *
1745 * We'll move forward by $invalid_length bytes and continue processing.
1746 * Later on, during the string decoding, we'll replace the invalid bytes with U+FFFD
1747 * via maximal subpart”replacement.
1748 */
1749 $matched_bytes = $invalid_length;
1750 } else {
1751 $matched_bytes = $new_at - $at;
1752 }
1753
1754 $bytes_consumed = $matched_bytes;
1755 return substr( $this->css, $at, $matched_bytes );
1756 }
1757
1758 /**
1759 * Checks if current position starts a valid escape sequence.
1760 *
1761 * A valid escape is a backslash not followed by a newline or EOF.
1762 *
1763 * @see https://www.w3.org/TR/css-syntax-3/#starts-with-a-valid-escape
1764 *
1765 * @param int $offset Byte offset.
1766 * @return bool
1767 */
1768 private function is_valid_escape( int $offset ): bool {
1769 // If the first code point is not U+005C REVERSE SOLIDUS (\), return false.
1770 if ( $offset >= $this->length || '\\' !== $this->css[ $offset ] ) {
1771 return false;
1772 }
1773 // Otherwise, if the second code point is a newline, return false.
1774 if ( $offset + 1 >= $this->length ) {
1775 // Second code point is EOF - this is a valid escape per spec (weird!)
1776 // Are we sure we're interpreting the spec correctly?
1777 return true;
1778 }
1779
1780 // Otherwise, if the second code point is not a newline, return true.
1781 return (
1782 "\n" !== $this->css[ $offset + 1 ] &&
1783
1784 // Form feed is normalized to newline during preprocessing.
1785 "\f" !== $this->css[ $offset + 1 ] &&
1786
1787 // Carriage return is normalized to newline during preprocessing.
1788 "\r" !== $this->css[ $offset + 1 ]
1789
1790 // We don't need to check for \r\n separately here. The \r check alone covers
1791 // that scenario.
1792 );
1793 }
1794
1795 /**
1796 * Checks if the next 3 code points would start a number.
1797 *
1798 * @see https://www.w3.org/TR/css-syntax-3/#starts-with-a-number
1799 *
1800 * @return bool
1801 */
1802 private function would_next_3_code_points_start_a_number(): bool {
1803 if ( $this->at >= $this->length ) {
1804 return false;
1805 }
1806
1807 // Look at the first code point.
1808
1809 // U+002B PLUS SIGN (+) or U+002D HYPHEN-MINUS (-).
1810 if ( '+' === $this->css[ $this->at ] || '-' === $this->css[ $this->at ] ) {
1811 if ( $this->at + 1 >= $this->length ) {
1812 return false;
1813 }
1814 // If the second code point is a digit, return true.
1815 if ( $this->css[ $this->at + 1 ] >= '0' && $this->css[ $this->at + 1 ] <= '9' ) {
1816 return true;
1817 }
1818 // Otherwise, the second code point must be a full stop (.) and the third code point must be a digit.
1819 if ( '.' === $this->css[ $this->at + 1 ] && $this->at + 2 < $this->length ) {
1820 return $this->css[ $this->at + 2 ] >= '0' && $this->css[ $this->at + 2 ] <= '9';
1821 }
1822
1823 // Otherwise, return false.
1824 return false;
1825 }
1826
1827 // U+002E FULL STOP (.).
1828 if ( '.' === $this->css[ $this->at ] ) {
1829 if ( $this->at + 1 >= $this->length ) {
1830 return false;
1831 }
1832 return $this->css[ $this->at + 1 ] >= '0' && $this->css[ $this->at + 1 ] <= '9';
1833 }
1834
1835 // Digit.
1836 if ( $this->css[ $this->at ] >= '0' && $this->css[ $this->at ] <= '9' ) {
1837 return true;
1838 }
1839
1840 // Anything else – return false.
1841 return false;
1842 }
1843
1844 /**
1845 * Checks if three code points would start an identifier sequence.
1846 *
1847 * This implements the CSS spec's "Check if three code points would start an ident sequence"
1848 * algorithm, which checks the code point at $offset and the following two code points.
1849 *
1850 * NOTE: "Three code points" means three Unicode code points, not three bytes.
1851 * Multi-byte UTF-8 sequences count as single code points.
1852 *
1853 * @see https://www.w3.org/TR/css-syntax-3/#would-start-an-identifier
1854 *
1855 * @param int $offset Byte offset of the first code point to check.
1856 * @return bool
1857 */
1858 private function check_if_3_code_points_start_an_ident_sequence( int $offset ): bool {
1859 if ( $offset >= $this->length ) {
1860 return false;
1861 }
1862
1863 if ( '-' === $this->css[ $offset ] ) {
1864 // If the second code point is a U+002D HYPHEN-MINUS (-), return true.
1865 // e.g. --custom-property.
1866 if ( $offset + 1 < $this->length && '-' === $this->css[ $offset + 1 ] ) {
1867 return true;
1868 }
1869 // Otherwise, check if the second code point is an ident-START code point or valid escape.
1870 // Note: After a hyphen, only ident-START code points are valid, NOT digits or hyphens.
1871 ++$offset;
1872 }
1873
1874 return $this->consume_ident_start_codepoint( $offset ) > 0 || $this->is_valid_escape( $offset );
1875 }
1876 }
1877