Minifier::setPcreRecursionLimit()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
/*!
4
 * CssMin
5
 * Author: Tubal Martin - http://tubalmartin.me/
6
 * Repo: https://github.com/tubalmartin/YUI-CSS-compressor-PHP-port
7
 *
8
 * This is a PHP port of the CSS minification tool distributed with YUICompressor,
9
 * itself a port of the cssmin utility by Isaac Schlueter - http://foohack.com/
10
 * Permission is hereby granted to use the PHP version under the same
11
 * conditions as the YUICompressor.
12
 */
13
14
/*!
15
 * YUI Compressor
16
 * http://developer.yahoo.com/yui/compressor/
17
 * Author: Julien Lecomte - http://www.julienlecomte.net/
18
 * Copyright (c) 2013 Yahoo! Inc. All rights reserved.
19
 * The copyrights embodied in the content of this file are licensed
20
 * by Yahoo! Inc. under the BSD (revised) open source license.
21
 */
22
23
namespace Autoptimize\tubalmartin\CssMin;
24
25
class Minifier
26
{
27
    const QUERY_FRACTION = '_CSSMIN_QF_';
28
    const COMMENT_TOKEN = '_CSSMIN_CMT_%d_';
29
    const COMMENT_TOKEN_START = '_CSSMIN_CMT_';
30
    const RULE_BODY_TOKEN = '_CSSMIN_RBT_%d_';
31
    const PRESERVED_TOKEN = '_CSSMIN_PTK_%d_';
32
    const UNQUOTED_FONT_TOKEN = '_CSSMIN_UFT_%d_';
33
34
    // Token lists
35
    private $comments = array();
36
    private $ruleBodies = array();
37
    private $preservedTokens = array();
38
    private $unquotedFontTokens = array();
39
40
    // Output options
41
    private $keepImportantComments = true;
42
    private $keepSourceMapComment = false;
43
    private $linebreakPosition = 0;
44
45
    // PHP ini limits
46
    private $raisePhpLimits;
47
    private $memoryLimit;
48
    private $maxExecutionTime = 60; // 1 min
49
    private $pcreBacktrackLimit;
50
    private $pcreRecursionLimit;
51
52
    // Color maps
53
    private $hexToNamedColorsMap;
54
    private $namedToHexColorsMap;
55
56
    // Regexes
57
    private $numRegex;
58
    private $charsetRegex = '/@charset [^;]+;/Si';
59
    private $importRegex = '/@import [^;]+;/Si';
60
    private $namespaceRegex = '/@namespace [^;]+;/Si';
61
    private $namedToHexColorsRegex;
62
    private $shortenOneZeroesRegex;
63
    private $shortenTwoZeroesRegex;
64
    private $shortenThreeZeroesRegex;
65
    private $shortenFourZeroesRegex;
66
    private $unitsGroupRegex = '(?:ch|cm|em|ex|gd|in|mm|px|pt|pc|q|rem|vh|vmax|vmin|vw|%)';
67
    private $unquotedFontsRegex = '/(font-family:|font:)([^\'"]+?)[^};]*/Si';
68
69
    /**
70
     * @param bool|int $raisePhpLimits If true, PHP settings will be raised if needed
71
     */
72
    public function __construct($raisePhpLimits = true)
73
    {
74
        $this->raisePhpLimits = (bool) $raisePhpLimits;
75
        $this->memoryLimit = 128 * 1048576; // 128MB in bytes
76
        $this->pcreBacktrackLimit = 1000 * 1000;
77
        $this->pcreRecursionLimit = 500 * 1000;
78
        $this->hexToNamedColorsMap = Colors::getHexToNamedMap();
79
        $this->namedToHexColorsMap = Colors::getNamedToHexMap();
80
        $this->namedToHexColorsRegex = sprintf(
81
            '/([:,( ])(%s)( |,|\)|;|$)/Si',
82
            implode('|', array_keys($this->namedToHexColorsMap))
83
        );
84
        $this->numRegex = sprintf('-?\d*\.?\d+%s?', $this->unitsGroupRegex);
85
        $this->setShortenZeroValuesRegexes();
86
    }
87
88
    /**
89
     * Parses & minifies the given input CSS string
90
     * @param string $css
91
     * @return string
92
     */
93
    public function run($css = '')
94
    {
95
        if (empty($css) || !is_string($css)) {
96
            return '';
97
        }
98
99
        $this->resetRunProperties();
100
101
        if ($this->raisePhpLimits) {
102
            $this->doRaisePhpLimits();
103
        }
104
105
        return $this->minify($css);
106
    }
107
108
    /**
109
     * Sets whether to keep or remove sourcemap special comment.
110
     * Sourcemap comments are removed by default.
111
     * @param bool $keepSourceMapComment
112
     */
113
    public function keepSourceMapComment($keepSourceMapComment = true)
114
    {
115
        $this->keepSourceMapComment = (bool) $keepSourceMapComment;
116
    }
117
118
    /**
119
     * Sets whether to keep or remove important comments.
120
     * Important comments outside of a declaration block are kept by default.
121
     * @param bool $removeImportantComments
122
     */
123
    public function removeImportantComments($removeImportantComments = true)
124
    {
125
        $this->keepImportantComments = !(bool) $removeImportantComments;
126
    }
127
128
    /**
129
     * Sets the approximate column after which long lines will be splitted in the output
130
     * with a linebreak.
131
     * @param int $position
132
     */
133
    public function setLineBreakPosition($position)
134
    {
135
        $this->linebreakPosition = (int) $position;
136
    }
137
138
    /**
139
     * Sets the memory limit for this script
140
     * @param int|string $limit
141
     */
142
    public function setMemoryLimit($limit)
143
    {
144
        $this->memoryLimit = Utils::normalizeInt($limit);
145
    }
146
147
    /**
148
     * Sets the maximum execution time for this script
149
     * @param int|string $seconds
150
     */
151
    public function setMaxExecutionTime($seconds)
152
    {
153
        $this->maxExecutionTime = (int) $seconds;
154
    }
155
156
    /**
157
     * Sets the PCRE backtrack limit for this script
158
     * @param int $limit
159
     */
160
    public function setPcreBacktrackLimit($limit)
161
    {
162
        $this->pcreBacktrackLimit = (int) $limit;
163
    }
164
165
    /**
166
     * Sets the PCRE recursion limit for this script
167
     * @param int $limit
168
     */
169
    public function setPcreRecursionLimit($limit)
170
    {
171
        $this->pcreRecursionLimit = (int) $limit;
172
    }
173
174
    /**
175
     * Builds regular expressions needed for shortening zero values
176
     */
177
    private function setShortenZeroValuesRegexes()
178
    {
179
        $zeroRegex = '0'. $this->unitsGroupRegex;
180
        $numOrPosRegex = '('. $this->numRegex .'|top|left|bottom|right|center) ';
181
        $oneZeroSafeProperties = array(
182
            '(?:line-)?height',
183
            '(?:(?:min|max)-)?width',
184
            'top',
185
            'left',
186
            'background-position',
187
            'bottom',
188
            'right',
189
            'border(?:-(?:top|left|bottom|right))?(?:-width)?',
190
            'border-(?:(?:top|bottom)-(?:left|right)-)?radius',
191
            'column-(?:gap|width)',
192
            'margin(?:-(?:top|left|bottom|right))?',
193
            'outline-width',
194
            'padding(?:-(?:top|left|bottom|right))?'
195
        );
196
197
        // First zero regex
198
        $regex = '/(^|;)('. implode('|', $oneZeroSafeProperties) .'):%s/Si';
199
        $this->shortenOneZeroesRegex = sprintf($regex, $zeroRegex);
200
201
        // Multiple zeroes regexes
202
        $regex = '/(^|;)(margin|padding|border-(?:width|radius)|background-position):%s/Si';
203
        $this->shortenTwoZeroesRegex = sprintf($regex, $numOrPosRegex . $zeroRegex);
204
        $this->shortenThreeZeroesRegex = sprintf($regex, $numOrPosRegex . $numOrPosRegex . $zeroRegex);
205
        $this->shortenFourZeroesRegex = sprintf($regex, $numOrPosRegex . $numOrPosRegex . $numOrPosRegex . $zeroRegex);
206
    }
207
208
    /**
209
     * Resets properties whose value may change between runs
210
     */
211
    private function resetRunProperties()
212
    {
213
        $this->comments = array();
214
        $this->ruleBodies = array();
215
        $this->preservedTokens = array();
216
    }
217
218
    /**
219
     * Tries to configure PHP to use at least the suggested minimum settings
220
     * @return void
221
     */
222
    private function doRaisePhpLimits()
223
    {
224
        $phpLimits = array(
225
            'memory_limit' => $this->memoryLimit,
226
            'max_execution_time' => $this->maxExecutionTime,
227
            'pcre.backtrack_limit' => $this->pcreBacktrackLimit,
228
            'pcre.recursion_limit' =>  $this->pcreRecursionLimit
229
        );
230
231
        // If current settings are higher respect them.
232
        foreach ($phpLimits as $name => $suggested) {
233
            $current = Utils::normalizeInt(ini_get($name));
234
235
            if ($current >= $suggested) {
236
                continue;
237
            }
238
239
            // memoryLimit exception: allow -1 for "no memory limit".
240
            if ($name === 'memory_limit' && $current === -1) {
241
                continue;
242
            }
243
244
            // maxExecutionTime exception: allow 0 for "no memory limit".
245
            if ($name === 'max_execution_time' && $current === 0) {
246
                continue;
247
            }
248
249
            ini_set($name, $suggested);
250
        }
251
    }
252
253
    /**
254
     * Registers a preserved token
255
     * @param string $token
256
     * @return string The token ID string
257
     */
258
    private function registerPreservedToken($token)
259
    {
260
        $tokenId = sprintf(self::PRESERVED_TOKEN, count($this->preservedTokens));
261
        $this->preservedTokens[$tokenId] = $token;
262
        return $tokenId;
263
    }
264
265
    /**
266
     * Registers a candidate comment token
267
     * @param string $comment
268
     * @return string The comment token ID string
269
     */
270
    private function registerCommentToken($comment)
271
    {
272
        $tokenId = sprintf(self::COMMENT_TOKEN, count($this->comments));
273
        $this->comments[$tokenId] = $comment;
274
        return $tokenId;
275
    }
276
277
    /**
278
     * Registers a rule body token
279
     * @param string $body the minified rule body
280
     * @return string The rule body token ID string
281
     */
282 View Code Duplication
    private function registerRuleBodyToken($body)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
283
    {
284
        if (empty($body)) {
285
            return '';
286
        }
287
288
        $tokenId = sprintf(self::RULE_BODY_TOKEN, count($this->ruleBodies));
289
        $this->ruleBodies[$tokenId] = $body;
290
        return $tokenId;
291
    }
292
293 View Code Duplication
    private function registerUnquotedFontToken($body)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
294
    {
295
        if (empty($body)) {
296
            return '';
297
        }
298
299
        $tokenId = sprintf(self::UNQUOTED_FONT_TOKEN, count($this->unquotedFontTokens));
300
        $this->unquotedFontTokens[$tokenId] = $body;
301
        return $tokenId;
302
    }
303
304
    /**
305
     * Parses & minifies the given input CSS string
306
     * @param string $css
307
     * @return string
308
     */
309
    private function minify($css)
310
    {
311
        // Process data urls
312
        $css = $this->processDataUrls($css);
313
314
        // Process comments
315
        $css = preg_replace_callback(
316
            '/(?<!\\\\)\/\*(.*?)\*(?<!\\\\)\//Ss',
317
            array($this, 'processCommentsCallback'),
318
            $css
319
        );
320
321
        // IE7: Process Microsoft matrix filters (whitespaces between Matrix parameters). Can contain strings inside.
322
        $css = preg_replace_callback(
323
            '/filter:\s*progid:DXImageTransform\.Microsoft\.Matrix\(([^)]+)\)/Ss',
324
            array($this, 'processOldIeSpecificMatrixDefinitionCallback'),
325
            $css
326
        );
327
328
        // Process quoted unquotable attribute selectors to unquote them. Covers most common cases.
329
        // Likelyhood of a quoted attribute selector being a substring in a string: Very very low.
330
        $css = preg_replace(
331
            '/\[\s*([a-z][a-z-]+)\s*([\*\|\^\$~]?=)\s*[\'"](-?[a-z_][a-z0-9-_]+)[\'"]\s*\]/Ssi',
332
            '[$1$2$3]',
333
            $css
334
        );
335
336
        // Process strings so their content doesn't get accidentally minified
337
        $css = preg_replace_callback(
338
            '/(?:"(?:[^\\\\"]|\\\\.|\\\\)*")|'."(?:'(?:[^\\\\']|\\\\.|\\\\)*')/S",
339
            array($this, 'processStringsCallback'),
340
            $css
341
        );
342
343
        // Normalize all whitespace strings to single spaces. Easier to work with that way.
344
        $css = preg_replace('/\s+/S', ' ', $css);
345
346
        // Process import At-rules with unquoted URLs so URI reserved characters such as a semicolon may be used safely.
347
        $css = preg_replace_callback(
348
            '/@import url\(([^\'"]+?)\)( |;)/Si',
349
            array($this, 'processImportUnquotedUrlAtRulesCallback'),
350
            $css
351
        );
352
353
        // Process comments
354
        $css = $this->processComments($css);
355
356
        // Process rule bodies
357
        $css = $this->processRuleBodies($css);
358
359
        // Process at-rules and selectors
360
        $css = $this->processAtRulesAndSelectors($css);
361
362
        // Restore preserved rule bodies before splitting
363
        $css = strtr($css, $this->ruleBodies);
364
365
        // Split long lines in output if required
366
        $css = $this->processLongLineSplitting($css);
367
368
        // Restore preserved comments and strings
369
        $css = strtr($css, $this->preservedTokens);
370
371
        return trim($css);
372
    }
373
374
    /**
375
     * Searches & replaces all data urls with tokens before we start compressing,
376
     * to avoid performance issues running some of the subsequent regexes against large string chunks.
377
     * @param string $css
378
     * @return string
379
     */
380
    private function processDataUrls($css)
381
    {
382
        $ret = '';
383
        $searchOffset = $substrOffset = 0;
384
385
        // Since we need to account for non-base64 data urls, we need to handle
386
        // ' and ) being part of the data string.
387
        while (preg_match('/url\(\s*(["\']?)data:/Si', $css, $m, PREG_OFFSET_CAPTURE, $searchOffset)) {
388
            $matchStartIndex = $m[0][1];
389
            $dataStartIndex = $matchStartIndex + 4; // url( length
390
            $searchOffset = $matchStartIndex + strlen($m[0][0]);
391
            $terminator = $m[1][0]; // ', " or empty (not quoted)
392
            $terminatorRegex = '/(?<!\\\\)'. (strlen($terminator) === 0 ? '' : $terminator.'\s*') .'(\))/S';
393
394
            $ret .= substr($css, $substrOffset, $matchStartIndex - $substrOffset);
395
396
            // Terminator found
397
            if (preg_match($terminatorRegex, $css, $matches, PREG_OFFSET_CAPTURE, $searchOffset)) {
398
                $matchEndIndex = $matches[1][1];
399
                $searchOffset = $matchEndIndex + 1;
400
                $token = substr($css, $dataStartIndex, $matchEndIndex - $dataStartIndex);
401
402
                // Remove all spaces only for base64 encoded URLs.
403
                if (stripos($token, 'base64,') !== false) {
404
                    $token = preg_replace('/\s+/S', '', $token);
405
                }
406
407
                $ret .= 'url('. $this->registerPreservedToken(trim($token)) .')';
408
            // No end terminator found, re-add the whole match. Should we throw/warn here?
409
            } else {
410
                $ret .= substr($css, $matchStartIndex, $searchOffset - $matchStartIndex);
411
            }
412
413
            $substrOffset = $searchOffset;
414
        }
415
416
        $ret .= substr($css, $substrOffset);
417
418
        return $ret;
419
    }
420
421
    /**
422
     * Registers all comments found as candidates to be preserved.
423
     * @param array $matches
424
     * @return string
425
     */
426
    private function processCommentsCallback($matches)
427
    {
428
        return '/*'. $this->registerCommentToken($matches[1]) .'*/';
429
    }
430
431
    /**
432
     * Preserves old IE Matrix string definition
433
     * @param array $matches
434
     * @return string
435
     */
436
    private function processOldIeSpecificMatrixDefinitionCallback($matches)
437
    {
438
        return 'filter:progid:DXImageTransform.Microsoft.Matrix('. $this->registerPreservedToken($matches[1]) .')';
439
    }
440
441
    /**
442
     * Preserves strings found
443
     * @param array $matches
444
     * @return string
445
     */
446
    private function processStringsCallback($matches)
447
    {
448
        $match = $matches[0];
449
        $quote = substr($match, 0, 1);
450
        $match = substr($match, 1, -1);
451
452
        // maybe the string contains a comment-like substring?
453
        // one, maybe more? put'em back then
454
        if (strpos($match, self::COMMENT_TOKEN_START) !== false) {
455
            $match = strtr($match, $this->comments);
456
        }
457
458
        // minify alpha opacity in filter strings
459
        $match = str_ireplace('progid:DXImageTransform.Microsoft.Alpha(Opacity=', 'alpha(opacity=', $match);
460
461
        return $quote . $this->registerPreservedToken($match) . $quote;
462
    }
463
464
    /**
465
     * Searches & replaces all import at-rule unquoted urls with tokens so URI reserved characters such as a semicolon
466
     * may be used safely in a URL.
467
     * @param array $matches
468
     * @return string
469
     */
470
    private function processImportUnquotedUrlAtRulesCallback($matches)
471
    {
472
        return '@import url('. $this->registerPreservedToken($matches[1]) .')'. $matches[2];
473
    }
474
475
    /**
476
     * Preserves or removes comments found.
477
     * @param string $css
478
     * @return string
479
     */
480
    private function processComments($css)
481
    {
482
        foreach ($this->comments as $commentId => $comment) {
483
            $commentIdString = '/*'. $commentId .'*/';
484
485
            // ! in the first position of the comment means preserve
486
            // so push to the preserved tokens keeping the !
487 View Code Duplication
            if ($this->keepImportantComments && strpos($comment, '!') === 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
488
                $preservedTokenId = $this->registerPreservedToken($comment);
489
                // Put new lines before and after /*! important comments
490
                $css = str_replace($commentIdString, "\n/*$preservedTokenId*/\n", $css);
491
                continue;
492
            }
493
494
            // # sourceMappingURL= in the first position of the comment means sourcemap
495
            // so push to the preserved tokens if {$this->keepSourceMapComment} is truthy.
496 View Code Duplication
            if ($this->keepSourceMapComment && strpos($comment, '# sourceMappingURL=') === 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
497
                $preservedTokenId = $this->registerPreservedToken($comment);
498
                // Add new line before the sourcemap comment
499
                $css = str_replace($commentIdString, "\n/*$preservedTokenId*/", $css);
500
                continue;
501
            }
502
503
            // Keep empty comments after child selectors (IE7 hack)
504
            // e.g. html >/**/ body
505
            if (strlen($comment) === 0 && strpos($css, '>/*'.$commentId) !== false) {
506
                $css = str_replace($commentId, $this->registerPreservedToken(''), $css);
507
                continue;
508
            }
509
510
            // in all other cases kill the comment
511
            $css = str_replace($commentIdString, '', $css);
512
        }
513
514
        // Normalize whitespace again
515
        $css = preg_replace('/ +/S', ' ', $css);
516
517
        return $css;
518
    }
519
520
    /**
521
     * Finds, minifies & preserves all rule bodies.
522
     * @param string $css the whole stylesheet.
523
     * @return string
524
     */
525
    private function processRuleBodies($css)
526
    {
527
        $ret = '';
528
        $searchOffset = $substrOffset = 0;
529
530
        while (($blockStartPos = strpos($css, '{', $searchOffset)) !== false) {
531
            $blockEndPos = strpos($css, '}', $blockStartPos);
532
            // When ending curly brace is missing, let's
533
            // behave like there was one at the end of the block...
534
            if ( false === $blockEndPos ) {
535
                $blockEndPos = strlen($css) - 1;
536
            }
537
            $nextBlockStartPos = strpos($css, '{', $blockStartPos + 1);
538
            $ret .= substr($css, $substrOffset, $blockStartPos - $substrOffset);
539
540
            if ($nextBlockStartPos !== false && $nextBlockStartPos < $blockEndPos) {
541
                $ret .= substr($css, $blockStartPos, $nextBlockStartPos - $blockStartPos);
542
                $searchOffset = $nextBlockStartPos;
543
            } else {
544
                $ruleBody = substr($css, $blockStartPos + 1, $blockEndPos - $blockStartPos - 1);
545
                $ruleBodyToken = $this->registerRuleBodyToken($this->processRuleBody($ruleBody));
546
                $ret .= '{'. $ruleBodyToken .'}';
547
                $searchOffset = $blockEndPos + 1;
548
            }
549
550
            $substrOffset = $searchOffset;
551
        }
552
553
        $ret .= substr($css, $substrOffset);
554
555
        return $ret;
556
    }
557
558
    /**
559
     * Compresses non-group rule bodies.
560
     * @param string $body The rule body without curly braces
561
     * @return string
562
     */
563
    private function processRuleBody($body)
564
    {
565
        $body = trim($body);
566
567
        // Remove spaces before the things that should not have spaces before them.
568
        $body = preg_replace('/ ([:=,)*\/;\n])/S', '$1', $body);
569
570
        // Remove the spaces after the things that should not have spaces after them.
571
        $body = preg_replace('/([:=,(*\/!;\n]) /S', '$1', $body);
572
573
        // Replace multiple semi-colons in a row by a single one
574
        $body = preg_replace('/;;+/S', ';', $body);
575
576
        // Remove semicolon before closing brace except when:
577
        // - The last property is prefixed with a `*` (lte IE7 hack) to avoid issues on Symbian S60 3.x browsers.
578
        if (!preg_match('/\*[a-z0-9-]+:[^;]+;$/Si', $body)) {
579
            $body = rtrim($body, ';');
580
        }
581
582
        // Remove important comments inside a rule body (because they make no sense here).
583
        if (strpos($body, '/*') !== false) {
584
            $body = preg_replace('/\n?\/\*[A-Z0-9_]+\*\/\n?/S', '', $body);
585
        }
586
587
        // Empty rule body? Exit :)
588
        if (empty($body)) {
589
            return '';
590
        }
591
592
        // Shorten font-weight values
593
        $body = preg_replace(
594
            array('/(font-weight:)bold\b/Si', '/(font-weight:)normal\b/Si'),
595
            array('${1}700', '${1}400'),
596
            $body
597
        );
598
599
        // Shorten background property
600
        $body = preg_replace('/(background:)(?:none|transparent)( !|;|$)/Si', '${1}0 0$2', $body);
601
602
        // Shorten opacity IE filter
603
        $body = str_ireplace('progid:DXImageTransform.Microsoft.Alpha(Opacity=', 'alpha(opacity=', $body);
604
605
        // Shorten colors from rgb(51,102,153) to #336699, rgb(100%,0%,0%) to #ff0000 (sRGB color space)
606
        // Shorten colors from hsl(0, 100%, 50%) to #ff0000 (sRGB color space)
607
        // This makes it more likely that it'll get further compressed in the next step.
608
        $body = preg_replace_callback(
609
            '/(rgb|hsl)\(([0-9,.% -]+)\)(.|$)/Si',
610
            array($this, 'shortenHslAndRgbToHexCallback'),
611
            $body
612
        );
613
614
        // Shorten colors from #AABBCC to #ABC or shorter color name:
615
        // - Look for hex colors which don't have a "=" in front of them (to avoid MSIE filters)
616
        $body = preg_replace_callback(
617
            '/(?<!=)#([0-9a-f]{3,6})( |,|\)|;|$)/Si',
618
            array($this, 'shortenHexColorsCallback'),
619
            $body
620
        );
621
622
        // Tokenize unquoted font names in order to hide them from
623
        // color name replacements.
624
        $body = preg_replace_callback(
625
            $this->unquotedFontsRegex,
626
            array($this, 'preserveUnquotedFontTokens'),
627
            $body
628
        );
629
630
        // Shorten long named colors with a shorter HEX counterpart: white -> #fff.
631
        // Run at least 2 times to cover most cases
632
        $body = preg_replace_callback(
633
            array($this->namedToHexColorsRegex, $this->namedToHexColorsRegex),
634
            array($this, 'shortenNamedColorsCallback'),
635
            $body
636
        );
637
638
        // Restore unquoted font tokens now after colors have been changed.
639
        $body = $this->restoreUnquotedFontTokens($body);
640
641
        // Replace positive sign from numbers before the leading space is removed.
642
        // +1.2em to 1.2em, +.8px to .8px, +2% to 2%
643
        $body = preg_replace('/([ :,(])\+(\.?\d+)/S', '$1$2', $body);
644
645
        // shorten ms to s
646
        $body = preg_replace_callback('/([ :,(])(-?)(\d{3,})ms/Si', function ($matches) {
647
            return $matches[1] . $matches[2] . ((int) $matches[3] / 1000) .'s';
648
        }, $body);
649
650
        // Remove leading zeros from integer and float numbers.
651
        // 000.6 to .6, -0.8 to -.8, 0050 to 50, -01.05 to -1.05
652
        $body = preg_replace('/([ :,(])(-?)0+([1-9]?\.?\d+)/S', '$1$2$3', $body);
653
654
        // Remove trailing zeros from float numbers.
655
        // -6.0100em to -6.01em, .0100 to .01, 1.200px to 1.2px
656
        $body = preg_replace('/([ :,(])(-?\d?\.\d+?)0+([^\d])/S', '$1$2$3', $body);
657
658
        // Remove trailing .0 -> -9.0 to -9
659
        $body = preg_replace('/([ :,(])(-?\d+)\.0([^\d])/S', '$1$2$3', $body);
660
661
        // Replace 0 length numbers with 0
662
        $body = preg_replace('/([ :,(])-?\.?0+([^\d])/S', '${1}0$2', $body);
663
664
        // Shorten zero values for safe properties only
665
        $body = preg_replace(
666
            array(
667
                $this->shortenOneZeroesRegex,
668
                $this->shortenTwoZeroesRegex,
669
                $this->shortenThreeZeroesRegex,
670
                $this->shortenFourZeroesRegex
671
            ),
672
            array(
673
                '$1$2:0',
674
                '$1$2:$3 0',
675
                '$1$2:$3 $4 0',
676
                '$1$2:$3 $4 $5 0'
677
            ),
678
            $body
679
        );
680
681
        // Replace 0 0 0; or 0 0 0 0; with 0 0 for background-position property.
682
        $body = preg_replace('/(background-position):0(?: 0){2,3}( !|;|$)/Si', '$1:0 0$2', $body);
683
684
        // Shorten suitable shorthand properties with repeated values
685
        $body = preg_replace(
686
            array(
687
                '/(margin|padding|border-(?:width|radius)):('.$this->numRegex.')(?: \2)+( !|;|$)/Si',
688
                '/(border-(?:style|color)):([#a-z0-9]+)(?: \2)+( !|;|$)/Si'
689
            ),
690
            '$1:$2$3',
691
            $body
692
        );
693
        $body = preg_replace(
694
            array(
695
                '/(margin|padding|border-(?:width|radius)):'.
696
                '('.$this->numRegex.') ('.$this->numRegex.') \2 \3( !|;|$)/Si',
697
                '/(border-(?:style|color)):([#a-z0-9]+) ([#a-z0-9]+) \2 \3( !|;|$)/Si'
698
            ),
699
            '$1:$2 $3$4',
700
            $body
701
        );
702
        $body = preg_replace(
703
            array(
704
                '/(margin|padding|border-(?:width|radius)):'.
705
                '('.$this->numRegex.') ('.$this->numRegex.') ('.$this->numRegex.') \3( !|;|$)/Si',
706
                '/(border-(?:style|color)):([#a-z0-9]+) ([#a-z0-9]+) ([#a-z0-9]+) \3( !|;|$)/Si'
707
            ),
708
            '$1:$2 $3 $4$5',
709
            $body
710
        );
711
712
        // Lowercase some common functions that can be values
713
        $body = preg_replace_callback(
714
            '/(?:attr|blur|brightness|circle|contrast|cubic-bezier|drop-shadow|ellipse|from|grayscale|'.
715
            'hsla?|hue-rotate|inset|invert|local|minmax|opacity|perspective|polygon|rgba?|rect|repeat|saturate|sepia|'.
716
            'steps|to|url|var|-webkit-gradient|'.
717
            '(?:-(?:atsc|khtml|moz|ms|o|wap|webkit)-)?(?:calc|(?:repeating-)?(?:linear|radial)-gradient))\(/Si',
718
            array($this, 'strtolowerCallback'),
719
            $body
720
        );
721
722
        // Lowercase all uppercase properties
723
        $body = preg_replace_callback('/(?:^|;)[A-Z-]+:/S', array($this, 'strtolowerCallback'), $body);
724
725
        return $body;
726
    }
727
728
    private function preserveUnquotedFontTokens($matches)
729
    {
730
        return $this->registerUnquotedFontToken($matches[0]);
731
    }
732
733
    private function restoreUnquotedFontTokens($body)
734
    {
735
        return strtr($body, $this->unquotedFontTokens);
736
    }
737
738
    /**
739
     * Compresses At-rules and selectors.
740
     * @param string $css the whole stylesheet with rule bodies tokenized.
741
     * @return string
742
     */
743
    private function processAtRulesAndSelectors($css)
744
    {
745
        $charset = '';
746
        $imports = '';
747
        $namespaces = '';
748
749
        // Remove spaces before the things that should not have spaces before them.
750
        $css = preg_replace('/ ([@{};>+)\]~=,\/\n])/S', '$1', $css);
751
752
        // Remove the spaces after the things that should not have spaces after them.
753
        $css = preg_replace('/([{}:;>+(\[~=,\/\n]) /S', '$1', $css);
754
755
        // Shorten shortable double colon (CSS3) pseudo-elements to single colon (CSS2)
756
        $css = preg_replace('/::(before|after|first-(?:line|letter))(\{|,)/Si', ':$1$2', $css);
757
758
        // Retain space for special IE6 cases
759
        $css = preg_replace_callback('/:first-(line|letter)(\{|,)/Si', function ($matches) {
760
            return ':first-'. strtolower($matches[1]) .' '. $matches[2];
761
        }, $css);
762
763
        // Find a fraction that may used in some @media queries such as: (min-aspect-ratio: 1/1)
764
        // Add token to add the "/" back in later
765
        $css = preg_replace('/\(([a-z-]+):([0-9]+)\/([0-9]+)\)/Si', '($1:$2'. self::QUERY_FRACTION .'$3)', $css);
766
767
        // Remove empty rule blocks up to 2 levels deep.
768
        $css = preg_replace(array_fill(0, 2, '/(\{)[^{};\/\n]+\{\}/S'), '$1', $css);
769
        $css = preg_replace('/[^{};\/\n]+\{\}/S', '', $css);
770
771
        // Two important comments next to each other? Remove extra newline.
772
        if ($this->keepImportantComments) {
773
            $css = str_replace("\n\n", "\n", $css);
774
        }
775
776
        // Restore fraction
777
        $css = str_replace(self::QUERY_FRACTION, '/', $css);
778
779
        // Lowercase some popular @directives
780
        $css = preg_replace_callback(
781
            '/(?<!\\\\)@(?:charset|document|font-face|import|(?:-(?:atsc|khtml|moz|ms|o|wap|webkit)-)?keyframes|media|'.
782
            'namespace|page|supports|viewport)/Si',
783
            array($this, 'strtolowerCallback'),
784
            $css
785
        );
786
787
        // Lowercase some popular media types
788
        $css = preg_replace_callback(
789
            '/[ ,](?:all|aural|braille|handheld|print|projection|screen|tty|tv|embossed|speech)[ ,;{]/Si',
790
            array($this, 'strtolowerCallback'),
791
            $css
792
        );
793
794
        // Lowercase some common pseudo-classes & pseudo-elements
795
        $css = preg_replace_callback(
796
            '/(?<!\\\\):(?:active|after|before|checked|default|disabled|empty|enabled|first-(?:child|of-type)|'.
797
            'focus(?:-within)?|hover|indeterminate|in-range|invalid|lang\(|last-(?:child|of-type)|left|link|not\(|'.
798
            'nth-(?:child|of-type)\(|nth-last-(?:child|of-type)\(|only-(?:child|of-type)|optional|out-of-range|'.
799
            'read-(?:only|write)|required|right|root|:selection|target|valid|visited)/Si',
800
            array($this, 'strtolowerCallback'),
801
            $css
802
        );
803
804
        // @charset handling
805
        if (preg_match($this->charsetRegex, $css, $matches)) {
806
            // Keep the first @charset at-rule found
807
            $charset = $matches[0];
808
            // Delete all @charset at-rules
809
            $css = preg_replace($this->charsetRegex, '', $css);
810
        }
811
812
        // @import handling
813
        $css = preg_replace_callback($this->importRegex, function ($matches) use (&$imports) {
814
            // Keep all @import at-rules found for later
815
            $imports .= $matches[0];
816
            // Delete all @import at-rules
817
            return '';
818
        }, $css);
819
820
        // @namespace handling
821
        $css = preg_replace_callback($this->namespaceRegex, function ($matches) use (&$namespaces) {
822
            // Keep all @namespace at-rules found for later
823
            $namespaces .= $matches[0];
824
            // Delete all @namespace at-rules
825
            return '';
826
        }, $css);
827
828
        // Order critical at-rules:
829
        // 1. @charset first
830
        // 2. @imports below @charset
831
        // 3. @namespaces below @imports
832
        $css = $charset . $imports . $namespaces . $css;
833
834
        return $css;
835
    }
836
837
    /**
838
     * Splits long lines after a specific column.
839
     *
840
     * Some source control tools don't like it when files containing lines longer
841
     * than, say 8000 characters, are checked in. The linebreak option is used in
842
     * that case to split long lines after a specific column.
843
     *
844
     * @param string $css the whole stylesheet.
845
     * @return string
846
     */
847
    private function processLongLineSplitting($css)
848
    {
849
        if ($this->linebreakPosition > 0) {
850
            $l = strlen($css);
851
            $offset = $this->linebreakPosition;
852
            while (preg_match('/(?<!\\\\)\}(?!\n)/S', $css, $matches, PREG_OFFSET_CAPTURE, $offset)) {
853
                $matchIndex = $matches[0][1];
854
                $css = substr_replace($css, "\n", $matchIndex + 1, 0);
855
                $offset = $matchIndex + 2 + $this->linebreakPosition;
856
                $l += 1;
857
                if ($offset > $l) {
858
                    break;
859
                }
860
            }
861
        }
862
863
        return $css;
864
    }
865
866
    /**
867
     * Converts hsl() & rgb() colors to HEX format.
868
     * @param $matches
869
     * @return string
870
     */
871
    private function shortenHslAndRgbToHexCallback($matches)
872
    {
873
        $type = $matches[1];
874
        $values = explode(',', $matches[2]);
875
        $terminator = $matches[3];
876
877
        if ($type === 'hsl') {
878
            $values = Utils::hslToRgb($values);
879
        }
880
881
        $hexColors = Utils::rgbToHex($values);
882
883
        // Restore space after rgb() or hsl() function in some cases such as:
884
        // background-image: linear-gradient(to bottom, rgb(210,180,140) 10%, rgb(255,0,0) 90%);
885
        if (!empty($terminator) && !preg_match('/[ ,);]/S', $terminator)) {
886
            $terminator = ' '. $terminator;
887
        }
888
889
        return '#'. implode('', $hexColors) . $terminator;
890
    }
891
892
    /**
893
     * Compresses HEX color values of the form #AABBCC to #ABC or short color name.
894
     * @param $matches
895
     * @return string
896
     */
897
    private function shortenHexColorsCallback($matches)
898
    {
899
        $hex = $matches[1];
900
901
        // Shorten suitable 6 chars HEX colors
902
        if (strlen($hex) === 6 && preg_match('/^([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3$/Si', $hex, $m)) {
903
            $hex = $m[1] . $m[2] . $m[3];
904
        }
905
906
        // Lowercase
907
        $hex = '#'. strtolower($hex);
908
909
        // Replace Hex colors with shorter color names
910
        $color = array_key_exists($hex, $this->hexToNamedColorsMap) ? $this->hexToNamedColorsMap[$hex] : $hex;
911
912
        return $color . $matches[2];
913
    }
914
915
    /**
916
     * Shortens all named colors with a shorter HEX counterpart for a set of safe properties
917
     * e.g. white -> #fff
918
     * @param array $matches
919
     * @return string
920
     */
921
    private function shortenNamedColorsCallback($matches)
922
    {
923
        return $matches[1] . $this->namedToHexColorsMap[strtolower($matches[2])] . $matches[3];
924
    }
925
926
    /**
927
     * Makes a string lowercase
928
     * @param array $matches
929
     * @return string
930
     */
931
    private function strtolowerCallback($matches)
932
    {
933
        return strtolower($matches[0]);
934
    }
935
}
936