Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.24% covered (success)
94.24%
229 / 243
76.19% covered (warning)
76.19%
16 / 21
CRAP
50.00% covered (danger)
50.00%
1 / 2
CSSJanus
100.00% covered (success)
100.00%
229 / 229
100.00% covered (success)
100.00%
16 / 16
32
100.00% covered (success)
100.00%
1 / 1
 buildPatterns
100.00% covered (success)
100.00%
89 / 89
100.00% covered (success)
100.00%
1 / 1
2
 transform
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
4
 fixDirection
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fixLtrRtlInURL
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fixLeftRightInURL
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fixLeftAndRight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fixCursorProperties
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 fixFourPartNotation
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fixBorderRadius
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 calculateBorderRadius
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 flipBorderRadiusValues
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 flipSign
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 fixShadows
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 fixTranslate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 fixBackgroundPosition
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 calculateNewBackgroundPosition
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
CSSJanusTokenizer
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 5
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 tokenize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 tokenizeCallback
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 detokenize
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 detokenizeCallback
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * PHP port of CSSJanus. https://www.mediawiki.org/wiki/CSSJanus
4 *
5 * Copyright 2020 Timo Tijhof
6 * Copyright 2014 Trevor Parscal
7 * Copyright 2010 Roan Kattouw
8 * Copyright 2008 Google Inc.
9 *
10 * Licensed under the Apache License, Version 2.0 (the "License");
11 * you may not use this file except in compliance with the License.
12 * You may obtain a copy of the License at
13 *
14 * http://www.apache.org/licenses/LICENSE-2.0
15 *
16 * Unless required by applicable law or agreed to in writing, software
17 * distributed under the License is distributed on an "AS IS" BASIS,
18 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19 * See the License for the specific language governing permissions and
20 * limitations under the License.
21 *
22 * @file
23 */
24
25/**
26 * CSSJanus is a utility that converts CSS stylesheets
27 * from left-to-right (LTR) to right-to-left (RTL).
28 */
29class CSSJanus {
30    private const TOKEN_TMP = '`TMP`';
31    private const TOKEN_COMMENT = '`COMMENT`';
32
33    private static $patterns = null;
34
35    private static function buildPatterns() {
36        if ( self::$patterns !== null ) {
37            return;
38        }
39        // Patterns defined as null are built dynamically
40        $patterns = [
41            'tmpToken' => '`TMP`',
42            'nonAscii' => '[\200-\377]',
43            'unicode' => '(?:(?:\\\\[0-9a-f]{1,6})(?:\r\n|\s)?)',
44            'num' => '(?:[0-9]*\.[0-9]+|[0-9]+)',
45            'unit' => '(?:em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)',
46            'body_selector' => 'body\s*{\s*',
47            'direction' => 'direction\s*:\s*',
48            'escape' => null,
49            'nmstart' => null,
50            'nmchar' => null,
51            'ident' => null,
52            'quantity' => null,
53            'possibly_negative_quantity' => null,
54            'color' => null,
55            'url_special_chars' => '[!#$%&*-~]',
56            'valid_after_uri_chars' => '[\'\"]?\s*',
57            'url_chars' => null,
58            'lookahead_not_open_brace' => null,
59            'lookahead_not_closing_paren' => null,
60            'lookahead_for_closing_paren' => null,
61            'lookahead_not_letter' => '(?![a-zA-Z])',
62            'lookbehind_not_letter' => '(?<![a-zA-Z])',
63            'chars_within_selector' => '[^\}]*?',
64            'noflip_annotation' => '\/\*\!?\s*@noflip\s*\*\/',
65            'noflip_single' => null,
66            'noflip_class' => null,
67            'comment' => '/\/\*[^*]*\*+([^\/*][^*]*\*+)*\//',
68            'direction_ltr' => null,
69            'direction_rtl' => null,
70            'left' => null,
71            'right' => null,
72            'left_in_url' => null,
73            'right_in_url' => null,
74            'ltr_in_url' => null,
75            'rtl_in_url' => null,
76            'cursor_east' => null,
77            'cursor_west' => null,
78            'four_notation_quantity' => null,
79            'four_notation_color' => null,
80            'border_radius' => null,
81            'box_shadow' => null,
82            'text_shadow1' => null,
83            'text_shadow2' => null,
84            'bg_horizontal_percentage' => null,
85            'bg_horizontal_percentage_x' => null,
86            'suffix' => '(\s*(?:!important\s*)?[;}])'
87        ];
88
89        // @codingStandardsIgnoreStart Generic.Files.LineLength.TooLong
90        $patterns['escape'] = "(?:{$patterns['unicode']}|\\\\[^\\r\\n\\f0-9a-f])";
91        $patterns['nmstart'] = "(?:[_a-z]|{$patterns['nonAscii']}|{$patterns['escape']})";
92        $patterns['nmchar'] = "(?:[_a-z0-9-]|{$patterns['nonAscii']}|{$patterns['escape']})";
93        $patterns['ident'] = "-?{$patterns['nmstart']}{$patterns['nmchar']}*";
94        $patterns['quantity'] = "{$patterns['num']}(?:\s*{$patterns['unit']}|{$patterns['ident']})?";
95        $patterns['possibly_negative_quantity'] = "((?:-?{$patterns['quantity']})|(?:inherit|auto))";
96        $patterns['color'] = "(#?{$patterns['nmchar']}+|(?:rgba?|hsla?)\([ \d.,%-]+\))";
97        // Use "*+" instead of "*?" to avoid reaching the backtracking limit.
98        // <https://phabricator.wikimedia.org/T326481>, <https://phabricator.wikimedia.org/T215746#4944830>.
99        $patterns['url_chars'] = "(?:{$patterns['url_special_chars']}|{$patterns['nonAscii']}|{$patterns['escape']})*+";
100        $patterns['lookahead_not_open_brace'] = "(?!({$patterns['nmchar']}|\\r?\\n|\s|#|\:|\.|\,|\+|>|~|\(|\)|\[|\]|=|\*=|~=|\^=|'[^']*'|\"[^\"]*\"|" . self::TOKEN_COMMENT . ")*+{)";
101        $patterns['lookahead_not_closing_paren'] = "(?!{$patterns['url_chars']}{$patterns['valid_after_uri_chars']}\))";
102        $patterns['lookahead_for_closing_paren'] = "(?={$patterns['url_chars']}{$patterns['valid_after_uri_chars']}\))";
103        $patterns['noflip_single'] = "/({$patterns['noflip_annotation']}{$patterns['lookahead_not_open_brace']}[^;}]+;?)/i";
104        $patterns['noflip_class'] = "/({$patterns['noflip_annotation']}{$patterns['chars_within_selector']}})/i";
105        $patterns['direction_ltr'] = "/({$patterns['direction']})ltr/i";
106        $patterns['direction_rtl'] = "/({$patterns['direction']})rtl/i";
107        $patterns['left'] = "/{$patterns['lookbehind_not_letter']}(left){$patterns['lookahead_not_letter']}{$patterns['lookahead_not_closing_paren']}{$patterns['lookahead_not_open_brace']}/i";
108        $patterns['right'] = "/{$patterns['lookbehind_not_letter']}(right){$patterns['lookahead_not_letter']}{$patterns['lookahead_not_closing_paren']}{$patterns['lookahead_not_open_brace']}/i";
109        $patterns['left_in_url'] = "/{$patterns['lookbehind_not_letter']}(left){$patterns['lookahead_for_closing_paren']}/i";
110        $patterns['right_in_url'] = "/{$patterns['lookbehind_not_letter']}(right){$patterns['lookahead_for_closing_paren']}/i";
111        $patterns['ltr_in_url'] = "/{$patterns['lookbehind_not_letter']}(ltr){$patterns['lookahead_for_closing_paren']}/i";
112        $patterns['rtl_in_url'] = "/{$patterns['lookbehind_not_letter']}(rtl){$patterns['lookahead_for_closing_paren']}/i";
113        $patterns['cursor_east'] = "/{$patterns['lookbehind_not_letter']}([ns]?)e-resize/";
114        $patterns['cursor_west'] = "/{$patterns['lookbehind_not_letter']}([ns]?)w-resize/";
115        $patterns['four_notation_quantity_props'] = "((?:margin|padding|border-width)\s*:\s*)";
116        $patterns['four_notation_quantity'] = "/{$patterns['four_notation_quantity_props']}{$patterns['possibly_negative_quantity']}(\s+){$patterns['possibly_negative_quantity']}(\s+){$patterns['possibly_negative_quantity']}(\s+){$patterns['possibly_negative_quantity']}{$patterns['suffix']}/i";
117        $patterns['four_notation_color'] = "/((?:-color|border-style)\s*:\s*){$patterns['color']}(\s+){$patterns['color']}(\s+){$patterns['color']}(\s+){$patterns['color']}{$patterns['suffix']}/i";
118        // border-radius: <length or percentage>{1,4} [optional: / <length or percentage>{1,4} ]
119        $patterns['border_radius'] = '/(border-radius\s*:\s*)' . $patterns['possibly_negative_quantity']
120            . '(?:(?:\s+' . $patterns['possibly_negative_quantity'] . ')(?:\s+' . $patterns['possibly_negative_quantity'] . ')?(?:\s+' . $patterns['possibly_negative_quantity'] . ')?)?'
121            . '(?:(?:(?:\s*\/\s*)' . $patterns['possibly_negative_quantity'] . ')(?:\s+' . $patterns['possibly_negative_quantity'] . ')?(?:\s+' . $patterns['possibly_negative_quantity'] . ')?(?:\s+' . $patterns['possibly_negative_quantity'] . ')?)?' . $patterns['suffix']
122            . '/i';
123        $patterns['box_shadow'] = "/(box-shadow\s*:\s*(?:inset\s*)?){$patterns['possibly_negative_quantity']}/i";
124        $patterns['text_shadow1'] = "/(text-shadow\s*:\s*){$patterns['possibly_negative_quantity']}(\s*){$patterns['color']}/i";
125        $patterns['text_shadow2'] = "/(text-shadow\s*:\s*){$patterns['color']}(\s*){$patterns['possibly_negative_quantity']}/i";
126        $patterns['text_shadow3'] = "/(text-shadow\s*:\s*){$patterns['possibly_negative_quantity']}/i";
127        $patterns['bg_horizontal_percentage'] = "/(background(?:-position)?\s*:\s*(?:[^:;}\s]+\s+)*?)({$patterns['quantity']})/i";
128        $patterns['bg_horizontal_percentage_x'] = "/(background-position-x\s*:\s*)(-?{$patterns['num']}%)/i";
129        $patterns['translate_x'] = "/(transform\s*:[^;}]*)(translateX\s*\(\s*){$patterns['possibly_negative_quantity']}(\s*\))/i";
130        $patterns['translate'] = "/(transform\s*:[^;}]*)(translate\s*\(\s*){$patterns['possibly_negative_quantity']}((?:\s*,\s*{$patterns['possibly_negative_quantity']}){0,2}\s*\))/i";
131        // @codingStandardsIgnoreEnd
132
133        self::$patterns = $patterns;
134    }
135
136    /**
137     * Transform an LTR stylesheet to RTL
138     *
139     * @param string $css Stylesheet to transform
140     * @param bool|array{transformDirInUrl?:bool,transformEdgeInUrl?:bool} $options Options array,
141     * or value of transformDirInUrl option (back-compat)
142     *  - transformDirInUrl: Transform directions in URLs (ltr/rtl). Default: false.
143     *  - transformEdgeInUrl: Transform edges in URLs (left/right). Default: false.
144     * @param bool $transformEdgeInUrl [optional] For back-compat
145     * @return string Transformed stylesheet
146     */
147    public static function transform( $css, $options = [], $transformEdgeInUrl = false ) {
148        if ( !is_array( $options ) ) {
149            $options = [
150                'transformDirInUrl' => (bool)$options,
151                'transformEdgeInUrl' => (bool)$transformEdgeInUrl,
152            ];
153        }
154
155        // Defaults
156        $options += [
157            'transformDirInUrl' => false,
158            'transformEdgeInUrl' => false,
159        ];
160
161        self::buildPatterns();
162
163        // We wrap tokens in ` , not ~ like the original implementation does.
164        // This was done because ` is not a legal character in CSS and can only
165        // occur in URLs, where we escape it to %60 before inserting our tokens.
166        $css = str_replace( '`', '%60', $css );
167
168        // Tokenize single line rules with /* @noflip */
169        $noFlipSingle = new CSSJanusTokenizer( self::$patterns['noflip_single'], '`NOFLIP_SINGLE`' );
170        $css = $noFlipSingle->tokenize( $css );
171
172        // Tokenize class rules with /* @noflip */
173        $noFlipClass = new CSSJanusTokenizer( self::$patterns['noflip_class'], '`NOFLIP_CLASS`' );
174        $css = $noFlipClass->tokenize( $css );
175
176        // Tokenize comments
177        $comments = new CSSJanusTokenizer( self::$patterns['comment'], self::TOKEN_COMMENT );
178        $css = $comments->tokenize( $css );
179
180        // LTR->RTL fixes start here
181        $css = self::fixDirection( $css );
182        if ( $options['transformDirInUrl'] ) {
183            $css = self::fixLtrRtlInURL( $css );
184        }
185
186        if ( $options['transformEdgeInUrl'] ) {
187            $css = self::fixLeftRightInURL( $css );
188        }
189        $css = self::fixLeftAndRight( $css );
190        $css = self::fixCursorProperties( $css );
191        $css = self::fixFourPartNotation( $css );
192        $css = self::fixBorderRadius( $css );
193        $css = self::fixBackgroundPosition( $css );
194        $css = self::fixShadows( $css );
195        $css = self::fixTranslate( $css );
196
197        // Detokenize stuff we tokenized before
198        $css = $comments->detokenize( $css );
199        $css = $noFlipClass->detokenize( $css );
200        $css = $noFlipSingle->detokenize( $css );
201
202        return $css;
203    }
204
205    /**
206     * Replace direction: ltr; with direction: rtl; and vice versa.
207     *
208     * The original implementation only does this inside body selectors
209     * and misses "body\n{\ndirection:ltr;\n}". This function does not have
210     * these problems.
211     *
212     * See https://code.google.com/p/cssjanus/issues/detail?id=15
213     *
214     * @param string $css
215     * @return string
216     */
217    private static function fixDirection( $css ) {
218        $css = preg_replace(
219            self::$patterns['direction_ltr'],
220            '$1' . self::TOKEN_TMP,
221            $css
222        );
223        $css = preg_replace( self::$patterns['direction_rtl'], '$1ltr', $css );
224        $css = str_replace( self::TOKEN_TMP, 'rtl', $css );
225
226        return $css;
227    }
228
229    /**
230     * Replace 'ltr' with 'rtl' and vice versa in background URLs
231     * @param string $css
232     * @return string
233     */
234    private static function fixLtrRtlInURL( $css ) {
235        $css = preg_replace( self::$patterns['ltr_in_url'], self::TOKEN_TMP, $css );
236        $css = preg_replace( self::$patterns['rtl_in_url'], 'ltr', $css );
237        $css = str_replace( self::TOKEN_TMP, 'rtl', $css );
238
239        return $css;
240    }
241
242    /**
243     * Replace 'left' with 'right' and vice versa in background URLs
244     * @param string $css
245     * @return string
246     */
247    private static function fixLeftRightInURL( $css ) {
248        $css = preg_replace( self::$patterns['left_in_url'], self::TOKEN_TMP, $css );
249        $css = preg_replace( self::$patterns['right_in_url'], 'left', $css );
250        $css = str_replace( self::TOKEN_TMP, 'right', $css );
251
252        return $css;
253    }
254
255    /**
256     * Flip rules like left: , padding-right: , etc.
257     * @param string $css
258     * @return string
259     */
260    private static function fixLeftAndRight( $css ) {
261        $css = preg_replace( self::$patterns['left'], self::TOKEN_TMP, $css );
262        $css = preg_replace( self::$patterns['right'], 'left', $css );
263        $css = str_replace( self::TOKEN_TMP, 'right', $css );
264
265        return $css;
266    }
267
268    /**
269     * Flip East and West in rules like cursor: nw-resize;
270     * @param string $css
271     * @return string
272     */
273    private static function fixCursorProperties( $css ) {
274        $css = preg_replace(
275            self::$patterns['cursor_east'],
276            '$1' . self::TOKEN_TMP,
277            $css
278        );
279        $css = preg_replace( self::$patterns['cursor_west'], '$1e-resize', $css );
280        $css = str_replace( self::TOKEN_TMP, 'w-resize', $css );
281
282        return $css;
283    }
284
285    /**
286     * Swap the second and fourth parts in four-part notation rules like
287     * padding: 1px 2px 3px 4px;
288     *
289     * Unlike the original implementation, this function doesn't suffer from
290     * the bug where whitespace is not preserved when flipping four-part rules
291     * and four-part color rules with multiple whitespace characters between
292     * colors are not recognized.
293     * See https://code.google.com/p/cssjanus/issues/detail?id=16
294     * @param string $css
295     * @return string
296     */
297    private static function fixFourPartNotation( $css ) {
298        $css = preg_replace( self::$patterns['four_notation_quantity'], '$1$2$3$8$5$6$7$4$9', $css );
299        $css = preg_replace( self::$patterns['four_notation_color'], '$1$2$3$8$5$6$7$4$9', $css );
300        return $css;
301    }
302
303    /**
304     * Swaps appropriate corners in border-radius values.
305     *
306     * @param string $css
307     * @return string
308     */
309    private static function fixBorderRadius( $css ) {
310        return preg_replace_callback(
311            self::$patterns['border_radius'],
312            [ self::class, 'calculateBorderRadius' ],
313            $css
314        );
315    }
316
317    /**
318     * Callback for fixBorderRadius()
319     * @param array $matches
320     * @return string
321     */
322    private static function calculateBorderRadius( $matches ) {
323        $pre = $matches[1];
324        $firstGroup = array_filter( array_slice( $matches, 2, 4 ), 'strlen' );
325        $secondGroup = array_filter( array_slice( $matches, 6, 4 ), 'strlen' );
326        $post = $matches[10] ?: '';
327
328        if ( $secondGroup ) {
329            $values = self::flipBorderRadiusValues( $firstGroup )
330                . ' / ' . self::flipBorderRadiusValues( $secondGroup );
331        } else {
332            $values = self::flipBorderRadiusValues( $firstGroup );
333        }
334
335        return $pre . $values . $post;
336    }
337
338    /**
339     * Callback for fixBorderRadius()
340     * @param array $values Matched values
341     * @return string Flipped values
342     */
343    private static function flipBorderRadiusValues( $values ) {
344        switch ( count( $values ) ) {
345            case 4:
346                $values = [ $values[1], $values[0], $values[3], $values[2] ];
347                break;
348            case 3:
349                $values = [ $values[1], $values[0], $values[1], $values[2] ];
350                break;
351            case 2:
352                $values = [ $values[1], $values[0] ];
353                break;
354            case 1:
355                $values = [ $values[0] ];
356                break;
357        }
358        return implode( ' ', $values );
359    }
360
361    /**
362     * Flips the sign of a CSS value, possibly with a unit.
363     *
364     * We can't just negate the value with unary minus due to the units.
365     *
366     * @param string $cssValue
367     * @return string
368     */
369    private static function flipSign( $cssValue ) {
370        // Don't mangle zeroes
371        if ( floatval( $cssValue ) === 0.0 ) {
372            return $cssValue;
373        } elseif ( $cssValue[0] === '-' ) {
374            return substr( $cssValue, 1 );
375        } else {
376            return "-" . $cssValue;
377        }
378    }
379
380    /**
381     * Negates horizontal offset in box-shadow and text-shadow rules.
382     *
383     * @param string $css
384     * @return string
385     */
386    private static function fixShadows( $css ) {
387        $css = preg_replace_callback( self::$patterns['box_shadow'], function ( $matches ) {
388            return $matches[1] . self::flipSign( $matches[2] );
389        }, $css );
390
391        $css = preg_replace_callback( self::$patterns['text_shadow1'], function ( $matches ) {
392            return $matches[1] . $matches[2] . $matches[3] . self::flipSign( $matches[4] );
393        }, $css );
394
395        $css = preg_replace_callback( self::$patterns['text_shadow2'], function ( $matches ) {
396            return $matches[1] . $matches[2] . $matches[3] . self::flipSign( $matches[4] );
397        }, $css );
398
399        $css = preg_replace_callback( self::$patterns['text_shadow3'], function ( $matches ) {
400            return $matches[1] . self::flipSign( $matches[2] );
401        }, $css );
402
403        return $css;
404    }
405
406    /**
407     * Negates horizontal offset in tranform: translate()
408     *
409     * @param string $css
410     * @return string
411     */
412    private static function fixTranslate( $css ) {
413        $css = preg_replace_callback( self::$patterns['translate'], function ( $matches ) {
414            return $matches[1] . $matches[2] . self::flipSign( $matches[3] ) . $matches[4];
415        }, $css );
416
417        $css = preg_replace_callback( self::$patterns['translate_x'], function ( $matches ) {
418            return $matches[1] . $matches[2] . self::flipSign( $matches[3] ) . $matches[4];
419        }, $css );
420
421        return $css;
422    }
423
424    /**
425     * Flip horizontal background percentages.
426     * @param string $css
427     * @return string
428     */
429    private static function fixBackgroundPosition( $css ) {
430        $replaced = preg_replace_callback(
431            self::$patterns['bg_horizontal_percentage'],
432            [ self::class, 'calculateNewBackgroundPosition' ],
433            $css
434        );
435        if ( $replaced !== null ) {
436            // preg_replace_callback() sometimes returns null
437            $css = $replaced;
438        }
439        $replaced = preg_replace_callback(
440            self::$patterns['bg_horizontal_percentage_x'],
441            [ self::class, 'calculateNewBackgroundPosition' ],
442            $css
443        );
444        if ( $replaced !== null ) {
445            $css = $replaced;
446        }
447
448        return $css;
449    }
450
451    /**
452     * Callback for fixBackgroundPosition()
453     * @param array $matches
454     * @return string
455     */
456    private static function calculateNewBackgroundPosition( $matches ) {
457        $value = $matches[2];
458        if ( substr( $value, -1 ) === '%' ) {
459            $idx = strpos( $value, '.' );
460            if ( $idx !== false ) {
461                $len = strlen( $value ) - $idx - 2;
462                $value = number_format( 100 - (float)$value, $len ) . '%';
463            } else {
464                $value = ( 100 - (float)$value ) . '%';
465            }
466        }
467        return $matches[1] . $value;
468    }
469}
470
471/**
472 * Utility class used by CSSJanus that tokenizes and untokenizes things we want
473 * to protect from being janused.
474 */
475class CSSJanusTokenizer {
476    private $regex;
477    private $token;
478    private $originals;
479
480    /**
481     * Constructor
482     * @param string $regex Regular expression whose matches to replace by a token.
483     * @param string $token Token
484     */
485    public function __construct( $regex, $token ) {
486        $this->regex = $regex;
487        $this->token = $token;
488        $this->originals = [];
489    }
490
491    /**
492     * Replace all occurrences of $regex in $str with a token and remember
493     * the original strings.
494     * @param string $str to tokenize
495     * @return string Tokenized string
496     */
497    public function tokenize( $str ) {
498        return preg_replace_callback( $this->regex, [ $this, 'tokenizeCallback' ], $str );
499    }
500
501    /**
502     * @param array $matches
503     * @return string
504     */
505    private function tokenizeCallback( $matches ) {
506        $this->originals[] = $matches[0];
507        return $this->token;
508    }
509
510    /**
511     * Replace tokens with their originals. If multiple strings were tokenized, it's important they be
512     * detokenized in exactly the SAME ORDER.
513     * @param string $str previously run through tokenize()
514     * @return string Original string
515     */
516    public function detokenize( $str ) {
517        // PHP has no function to replace only the first occurrence or to
518        // replace occurrences of the same string with different values,
519        // so we use preg_replace_callback() even though we don't really need a regex
520        return preg_replace_callback(
521            '/' . preg_quote( $this->token, '/' ) . '/',
522            [ $this, 'detokenizeCallback' ],
523            $str
524        );
525    }
526
527    /**
528     * @param array $matches
529     * @return mixed
530     */
531    private function detokenizeCallback( $matches ) {
532        $retval = current( $this->originals );
533        next( $this->originals );
534
535        return $retval;
536    }
537}