Completed
Push — master ( 8e7dca...6ec0d0 )
by frank
01:41
created

Minifier::run()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 3
nop 1
dl 0
loc 14
rs 9.7998
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
33
    // Token lists
34
    private $comments = array();
35
    private $ruleBodies = array();
36
    private $preservedTokens = array();
37
38
    // Output options
39
    private $keepImportantComments = true;
40
    private $keepSourceMapComment = false;
41
    private $linebreakPosition = 0;
42
43
    // PHP ini limits
44
    private $raisePhpLimits;
45
    private $memoryLimit;
46
    private $maxExecutionTime = 60; // 1 min
47
    private $pcreBacktrackLimit;
48
    private $pcreRecursionLimit;
49
50
    // Color maps
51
    private $hexToNamedColorsMap;
52
    private $namedToHexColorsMap;
53
54
    // Regexes
55
    private $numRegex;
56
    private $charsetRegex = '/@charset [^;]+;/Si';
57
    private $importRegex = '/@import [^;]+;/Si';
58
    private $namespaceRegex = '/@namespace [^;]+;/Si';
59
    private $namedToHexColorsRegex;
60
    private $shortenOneZeroesRegex;
61
    private $shortenTwoZeroesRegex;
62
    private $shortenThreeZeroesRegex;
63
    private $shortenFourZeroesRegex;
64
    private $unitsGroupRegex = '(?:ch|cm|em|ex|gd|in|mm|px|pt|pc|q|rem|vh|vmax|vmin|vw|%)';
65
66
    /**
67
     * @param bool|int $raisePhpLimits If true, PHP settings will be raised if needed
68
     */
69
    public function __construct($raisePhpLimits = true)
70
    {
71
        $this->raisePhpLimits = (bool) $raisePhpLimits;
72
        $this->memoryLimit = 128 * 1048576; // 128MB in bytes
73
        $this->pcreBacktrackLimit = 1000 * 1000;
74
        $this->pcreRecursionLimit = 500 * 1000;
75
        $this->hexToNamedColorsMap = Colors::getHexToNamedMap();
76
        $this->namedToHexColorsMap = Colors::getNamedToHexMap();
77
        $this->namedToHexColorsRegex = sprintf(
78
            '/([:,( ])(%s)( |,|\)|;|$)/Si',
79
            implode('|', array_keys($this->namedToHexColorsMap))
80
        );
81
        $this->numRegex = sprintf('-?\d*\.?\d+%s?', $this->unitsGroupRegex);
82
        $this->setShortenZeroValuesRegexes();
83
    }
84
85
    /**
86
     * Parses & minifies the given input CSS string
87
     * @param string $css
88
     * @return string
89
     */
90
    public function run($css = '')
91
    {
92
        if (empty($css) || !is_string($css)) {
93
            return '';
94
        }
95
96
        $this->resetRunProperties();
97
98
        if ($this->raisePhpLimits) {
99
            $this->doRaisePhpLimits();
100
        }
101
102
        return $this->minify($css);
103
    }
104
105
    /**
106
     * Sets whether to keep or remove sourcemap special comment.
107
     * Sourcemap comments are removed by default.
108
     * @param bool $keepSourceMapComment
109
     */
110
    public function keepSourceMapComment($keepSourceMapComment = true)
111
    {
112
        $this->keepSourceMapComment = (bool) $keepSourceMapComment;
113
    }
114
115
    /**
116
     * Sets whether to keep or remove important comments.
117
     * Important comments outside of a declaration block are kept by default.
118
     * @param bool $removeImportantComments
119
     */
120
    public function removeImportantComments($removeImportantComments = true)
121
    {
122
        $this->keepImportantComments = !(bool) $removeImportantComments;
123
    }
124
125
    /**
126
     * Sets the approximate column after which long lines will be splitted in the output
127
     * with a linebreak.
128
     * @param int $position
129
     */
130
    public function setLineBreakPosition($position)
131
    {
132
        $this->linebreakPosition = (int) $position;
133
    }
134
135
    /**
136
     * Sets the memory limit for this script
137
     * @param int|string $limit
138
     */
139
    public function setMemoryLimit($limit)
140
    {
141
        $this->memoryLimit = Utils::normalizeInt($limit);
142
    }
143
144
    /**
145
     * Sets the maximum execution time for this script
146
     * @param int|string $seconds
147
     */
148
    public function setMaxExecutionTime($seconds)
149
    {
150
        $this->maxExecutionTime = (int) $seconds;
151
    }
152
153
    /**
154
     * Sets the PCRE backtrack limit for this script
155
     * @param int $limit
156
     */
157
    public function setPcreBacktrackLimit($limit)
158
    {
159
        $this->pcreBacktrackLimit = (int) $limit;
160
    }
161
162
    /**
163
     * Sets the PCRE recursion limit for this script
164
     * @param int $limit
165
     */
166
    public function setPcreRecursionLimit($limit)
167
    {
168
        $this->pcreRecursionLimit = (int) $limit;
169
    }
170
171
    /**
172
     * Builds regular expressions needed for shortening zero values
173
     */
174
    private function setShortenZeroValuesRegexes()
175
    {
176
        $zeroRegex = '0'. $this->unitsGroupRegex;
177
        $numOrPosRegex = '('. $this->numRegex .'|top|left|bottom|right|center) ';
178
        $oneZeroSafeProperties = array(
179
            '(?:line-)?height',
180
            '(?:(?:min|max)-)?width',
181
            'top',
182
            'left',
183
            'background-position',
184
            'bottom',
185
            'right',
186
            'border(?:-(?:top|left|bottom|right))?(?:-width)?',
187
            'border-(?:(?:top|bottom)-(?:left|right)-)?radius',
188
            'column-(?:gap|width)',
189
            'margin(?:-(?:top|left|bottom|right))?',
190
            'outline-width',
191
            'padding(?:-(?:top|left|bottom|right))?'
192
        );
193
194
        // First zero regex
195
        $regex = '/(^|;)('. implode('|', $oneZeroSafeProperties) .'):%s/Si';
196
        $this->shortenOneZeroesRegex = sprintf($regex, $zeroRegex);
197
198
        // Multiple zeroes regexes
199
        $regex = '/(^|;)(margin|padding|border-(?:width|radius)|background-position):%s/Si';
200
        $this->shortenTwoZeroesRegex = sprintf($regex, $numOrPosRegex . $zeroRegex);
201
        $this->shortenThreeZeroesRegex = sprintf($regex, $numOrPosRegex . $numOrPosRegex . $zeroRegex);
202
        $this->shortenFourZeroesRegex = sprintf($regex, $numOrPosRegex . $numOrPosRegex . $numOrPosRegex . $zeroRegex);
203
    }
204
205
    /**
206
     * Resets properties whose value may change between runs
207
     */
208
    private function resetRunProperties()
209
    {
210
        $this->comments = array();
211
        $this->ruleBodies = array();
212
        $this->preservedTokens = array();
213
    }
214
215
    /**
216
     * Tries to configure PHP to use at least the suggested minimum settings
217
     * @return void
218
     */
219
    private function doRaisePhpLimits()
220
    {
221
        $phpLimits = array(
222
            'memory_limit' => $this->memoryLimit,
223
            'max_execution_time' => $this->maxExecutionTime,
224
            'pcre.backtrack_limit' => $this->pcreBacktrackLimit,
225
            'pcre.recursion_limit' =>  $this->pcreRecursionLimit
226
        );
227
228
        // If current settings are higher respect them.
229
        foreach ($phpLimits as $name => $suggested) {
230
            $current = Utils::normalizeInt(ini_get($name));
231
232
            if ($current >= $suggested) {
233
                continue;
234
            }
235
236
            // memoryLimit exception: allow -1 for "no memory limit".
237
            if ($name === 'memory_limit' && $current === -1) {
238
                continue;
239
            }
240
241
            // maxExecutionTime exception: allow 0 for "no memory limit".
242
            if ($name === 'max_execution_time' && $current === 0) {
243
                continue;
244
            }
245
246
            ini_set($name, $suggested);
247
        }
248
    }
249
250
    /**
251
     * Registers a preserved token
252
     * @param string $token
253
     * @return string The token ID string
254
     */
255
    private function registerPreservedToken($token)
256
    {
257
        $tokenId = sprintf(self::PRESERVED_TOKEN, count($this->preservedTokens));
258
        $this->preservedTokens[$tokenId] = $token;
259
        return $tokenId;
260
    }
261
262
    /**
263
     * Registers a candidate comment token
264
     * @param string $comment
265
     * @return string The comment token ID string
266
     */
267
    private function registerCommentToken($comment)
268
    {
269
        $tokenId = sprintf(self::COMMENT_TOKEN, count($this->comments));
270
        $this->comments[$tokenId] = $comment;
271
        return $tokenId;
272
    }
273
274
    /**
275
     * Registers a rule body token
276
     * @param string $body the minified rule body
277
     * @return string The rule body token ID string
278
     */
279
    private function registerRuleBodyToken($body)
280
    {
281
        if (empty($body)) {
282
            return '';
283
        }
284
285
        $tokenId = sprintf(self::RULE_BODY_TOKEN, count($this->ruleBodies));
286
        $this->ruleBodies[$tokenId] = $body;
287
        return $tokenId;
288
    }
289
290
    /**
291
     * Parses & minifies the given input CSS string
292
     * @param string $css
293
     * @return string
294
     */
295
    private function minify($css)
296
    {
297
        // Process data urls
298
        $css = $this->processDataUrls($css);
299
300
        // Process comments
301
        $css = preg_replace_callback(
302
            '/(?<!\\\\)\/\*(.*?)\*(?<!\\\\)\//Ss',
303
            array($this, 'processCommentsCallback'),
304
            $css
305
        );
306
307
        // IE7: Process Microsoft matrix filters (whitespaces between Matrix parameters). Can contain strings inside.
308
        $css = preg_replace_callback(
309
            '/filter:\s*progid:DXImageTransform\.Microsoft\.Matrix\(([^)]+)\)/Ss',
310
            array($this, 'processOldIeSpecificMatrixDefinitionCallback'),
311
            $css
312
        );
313
314
        // Process quoted unquotable attribute selectors to unquote them. Covers most common cases.
315
        // Likelyhood of a quoted attribute selector being a substring in a string: Very very low.
316
        $css = preg_replace(
317
            '/\[\s*([a-z][a-z-]+)\s*([\*\|\^\$~]?=)\s*[\'"](-?[a-z_][a-z0-9-_]+)[\'"]\s*\]/Ssi',
318
            '[$1$2$3]',
319
            $css
320
        );
321
322
        // Process strings so their content doesn't get accidentally minified
323
        $css = preg_replace_callback(
324
            '/(?:"(?:[^\\\\"]|\\\\.|\\\\)*")|'."(?:'(?:[^\\\\']|\\\\.|\\\\)*')/S",
325
            array($this, 'processStringsCallback'),
326
            $css
327
        );
328
329
        // Normalize all whitespace strings to single spaces. Easier to work with that way.
330
        $css = preg_replace('/\s+/S', ' ', $css);
331
332
        // Process import At-rules with unquoted URLs so URI reserved characters such as a semicolon may be used safely.
333
        $css = preg_replace_callback(
334
            '/@import url\(([^\'"]+?)\)( |;)/Si',
335
            array($this, 'processImportUnquotedUrlAtRulesCallback'),
336
            $css
337
        );
338
339
        // Process comments
340
        $css = $this->processComments($css);
341
342
        // Process rule bodies
343
        $css = $this->processRuleBodies($css);
344
345
        // Process at-rules and selectors
346
        $css = $this->processAtRulesAndSelectors($css);
347
348
        // Restore preserved rule bodies before splitting
349
        $css = strtr($css, $this->ruleBodies);
350
351
        // Split long lines in output if required
352
        $css = $this->processLongLineSplitting($css);
353
354
        // Restore preserved comments and strings
355
        $css = strtr($css, $this->preservedTokens);
356
357
        return trim($css);
358
    }
359
360
    /**
361
     * Searches & replaces all data urls with tokens before we start compressing,
362
     * to avoid performance issues running some of the subsequent regexes against large string chunks.
363
     * @param string $css
364
     * @return string
365
     */
366
    private function processDataUrls($css)
367
    {
368
        $ret = '';
369
        $searchOffset = $substrOffset = 0;
370
371
        // Since we need to account for non-base64 data urls, we need to handle
372
        // ' and ) being part of the data string.
373
        while (preg_match('/url\(\s*(["\']?)data:/Si', $css, $m, PREG_OFFSET_CAPTURE, $searchOffset)) {
374
            $matchStartIndex = $m[0][1];
375
            $dataStartIndex = $matchStartIndex + 4; // url( length
376
            $searchOffset = $matchStartIndex + strlen($m[0][0]);
377
            $terminator = $m[1][0]; // ', " or empty (not quoted)
378
            $terminatorRegex = '/(?<!\\\\)'. (strlen($terminator) === 0 ? '' : $terminator.'\s*') .'(\))/S';
379
380
            $ret .= substr($css, $substrOffset, $matchStartIndex - $substrOffset);
381
382
            // Terminator found
383
            if (preg_match($terminatorRegex, $css, $matches, PREG_OFFSET_CAPTURE, $searchOffset)) {
384
                $matchEndIndex = $matches[1][1];
385
                $searchOffset = $matchEndIndex + 1;
386
                $token = substr($css, $dataStartIndex, $matchEndIndex - $dataStartIndex);
387
388
                // Remove all spaces only for base64 encoded URLs.
389
                if (stripos($token, 'base64,') !== false) {
390
                    $token = preg_replace('/\s+/S', '', $token);
391
                }
392
393
                $ret .= 'url('. $this->registerPreservedToken(trim($token)) .')';
394
            // No end terminator found, re-add the whole match. Should we throw/warn here?
395
            } else {
396
                $ret .= substr($css, $matchStartIndex, $searchOffset - $matchStartIndex);
397
            }
398
399
            $substrOffset = $searchOffset;
400
        }
401
402
        $ret .= substr($css, $substrOffset);
403
404
        return $ret;
405
    }
406
407
    /**
408
     * Registers all comments found as candidates to be preserved.
409
     * @param array $matches
410
     * @return string
411
     */
412
    private function processCommentsCallback($matches)
413
    {
414
        return '/*'. $this->registerCommentToken($matches[1]) .'*/';
415
    }
416
417
    /**
418
     * Preserves old IE Matrix string definition
419
     * @param array $matches
420
     * @return string
421
     */
422
    private function processOldIeSpecificMatrixDefinitionCallback($matches)
423
    {
424
        return 'filter:progid:DXImageTransform.Microsoft.Matrix('. $this->registerPreservedToken($matches[1]) .')';
425
    }
426
427
    /**
428
     * Preserves strings found
429
     * @param array $matches
430
     * @return string
431
     */
432
    private function processStringsCallback($matches)
433
    {
434
        $match = $matches[0];
435
        $quote = substr($match, 0, 1);
436
        $match = substr($match, 1, -1);
437
438
        // maybe the string contains a comment-like substring?
439
        // one, maybe more? put'em back then
440
        if (strpos($match, self::COMMENT_TOKEN_START) !== false) {
441
            $match = strtr($match, $this->comments);
442
        }
443
444
        // minify alpha opacity in filter strings
445
        $match = str_ireplace('progid:DXImageTransform.Microsoft.Alpha(Opacity=', 'alpha(opacity=', $match);
446
447
        return $quote . $this->registerPreservedToken($match) . $quote;
448
    }
449
450
    /**
451
     * Searches & replaces all import at-rule unquoted urls with tokens so URI reserved characters such as a semicolon
452
     * may be used safely in a URL.
453
     * @param array $matches
454
     * @return string
455
     */
456
    private function processImportUnquotedUrlAtRulesCallback($matches)
457
    {
458
        return '@import url('. $this->registerPreservedToken($matches[1]) .')'. $matches[2];
459
    }
460
461
    /**
462
     * Preserves or removes comments found.
463
     * @param string $css
464
     * @return string
465
     */
466
    private function processComments($css)
467
    {
468
        foreach ($this->comments as $commentId => $comment) {
469
            $commentIdString = '/*'. $commentId .'*/';
470
471
            // ! in the first position of the comment means preserve
472
            // so push to the preserved tokens keeping the !
473 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...
474
                $preservedTokenId = $this->registerPreservedToken($comment);
475
                // Put new lines before and after /*! important comments
476
                $css = str_replace($commentIdString, "\n/*$preservedTokenId*/\n", $css);
477
                continue;
478
            }
479
480
            // # sourceMappingURL= in the first position of the comment means sourcemap
481
            // so push to the preserved tokens if {$this->keepSourceMapComment} is truthy.
482 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...
483
                $preservedTokenId = $this->registerPreservedToken($comment);
484
                // Add new line before the sourcemap comment
485
                $css = str_replace($commentIdString, "\n/*$preservedTokenId*/", $css);
486
                continue;
487
            }
488
489
            // Keep empty comments after child selectors (IE7 hack)
490
            // e.g. html >/**/ body
491
            if (strlen($comment) === 0 && strpos($css, '>/*'.$commentId) !== false) {
492
                $css = str_replace($commentId, $this->registerPreservedToken(''), $css);
493
                continue;
494
            }
495
496
            // in all other cases kill the comment
497
            $css = str_replace($commentIdString, '', $css);
498
        }
499
500
        // Normalize whitespace again
501
        $css = preg_replace('/ +/S', ' ', $css);
502
503
        return $css;
504
    }
505
506
    /**
507
     * Finds, minifies & preserves all rule bodies.
508
     * @param string $css the whole stylesheet.
509
     * @return string
510
     */
511
    private function processRuleBodies($css)
512
    {
513
        $ret = '';
514
        $searchOffset = $substrOffset = 0;
515
516
        while (($blockStartPos = strpos($css, '{', $searchOffset)) !== false) {
517
            $blockEndPos = strpos($css, '}', $blockStartPos);
518
            $nextBlockStartPos = strpos($css, '{', $blockStartPos + 1);
519
            $ret .= substr($css, $substrOffset, $blockStartPos - $substrOffset);
520
521
            if ($nextBlockStartPos !== false && $nextBlockStartPos < $blockEndPos) {
522
                $ret .= substr($css, $blockStartPos, $nextBlockStartPos - $blockStartPos);
523
                $searchOffset = $nextBlockStartPos;
524
            } else {
525
                $ruleBody = substr($css, $blockStartPos + 1, $blockEndPos - $blockStartPos - 1);
526
                $ruleBodyToken = $this->registerRuleBodyToken($this->processRuleBody($ruleBody));
0 ignored issues
show
Bug introduced by
It seems like $this->processRuleBody($ruleBody) targeting Autoptimize\tubalmartin\...fier::processRuleBody() can also be of type array<integer,string>; however, Autoptimize\tubalmartin\...registerRuleBodyToken() does only seem to accept string, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

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