PluginProbe
Page Optimize / trunk
Page Optimize vtrunk
trunk 0.4.2 0.4.3 0.4.4 0.4.6 0.4.7 0.5.0 0.5.1 0.5.2 0.5.3 0.5.4 0.5.5 0.5.6 0.5.7 0.5.8 0.6.0 0.6.1 0.6.2 0.6.3
page-optimize / cssmin / Minifier.php

Minifier.php in Page Optimize trunk, at cssmin/Minifier.php

936 lines 28.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 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 private function registerRuleBodyToken($body)
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 private function registerUnquotedFontToken($body)
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 if ($this->keepImportantComments && strpos($comment, '!') === 0) {
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 if ($this->keepSourceMapComment && strpos($comment, '# sourceMappingURL=') === 0) {
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