MDL-65744 lib: Update minify minify lib
[moodle.git] / lib / minify / matthiasmullie-minify / src / CSS.php
1 <?php
2 /**
3  * CSS Minifier
4  *
5  * Please report bugs on https://github.com/matthiasmullie/minify/issues
6  *
7  * @author Matthias Mullie <minify@mullie.eu>
8  * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
9  * @license MIT License
10  */
12 namespace MatthiasMullie\Minify;
14 use MatthiasMullie\Minify\Exceptions\FileImportException;
15 use MatthiasMullie\PathConverter\ConverterInterface;
16 use MatthiasMullie\PathConverter\Converter;
18 /**
19  * CSS minifier
20  *
21  * Please report bugs on https://github.com/matthiasmullie/minify/issues
22  *
23  * @package Minify
24  * @author Matthias Mullie <minify@mullie.eu>
25  * @author Tijs Verkoyen <minify@verkoyen.eu>
26  * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
27  * @license MIT License
28  */
29 class CSS extends Minify
30 {
31     /**
32      * @var int maximum inport size in kB
33      */
34     protected $maxImportSize = 5;
36     /**
37      * @var string[] valid import extensions
38      */
39     protected $importExtensions = array(
40         'gif' => 'data:image/gif',
41         'png' => 'data:image/png',
42         'jpe' => 'data:image/jpeg',
43         'jpg' => 'data:image/jpeg',
44         'jpeg' => 'data:image/jpeg',
45         'svg' => 'data:image/svg+xml',
46         'woff' => 'data:application/x-font-woff',
47         'tif' => 'image/tiff',
48         'tiff' => 'image/tiff',
49         'xbm' => 'image/x-xbitmap',
50     );
52     /**
53      * Set the maximum size if files to be imported.
54      *
55      * Files larger than this size (in kB) will not be imported into the CSS.
56      * Importing files into the CSS as data-uri will save you some connections,
57      * but we should only import relatively small decorative images so that our
58      * CSS file doesn't get too bulky.
59      *
60      * @param int $size Size in kB
61      */
62     public function setMaxImportSize($size)
63     {
64         $this->maxImportSize = $size;
65     }
67     /**
68      * Set the type of extensions to be imported into the CSS (to save network
69      * connections).
70      * Keys of the array should be the file extensions & respective values
71      * should be the data type.
72      *
73      * @param string[] $extensions Array of file extensions
74      */
75     public function setImportExtensions(array $extensions)
76     {
77         $this->importExtensions = $extensions;
78     }
80     /**
81      * Move any import statements to the top.
82      *
83      * @param string $content Nearly finished CSS content
84      *
85      * @return string
86      */
87     protected function moveImportsToTop($content)
88     {
89         if (preg_match_all('/(;?)(@import (?<url>url\()?(?P<quotes>["\']?).+?(?P=quotes)(?(url)\)));?/', $content, $matches)) {
90             // remove from content
91             foreach ($matches[0] as $import) {
92                 $content = str_replace($import, '', $content);
93             }
95             // add to top
96             $content = implode(';', $matches[2]).';'.trim($content, ';');
97         }
99         return $content;
100     }
102     /**
103      * Combine CSS from import statements.
104      *
105      * @import's will be loaded and their content merged into the original file,
106      * to save HTTP requests.
107      *
108      * @param string   $source  The file to combine imports for
109      * @param string   $content The CSS content to combine imports for
110      * @param string[] $parents Parent paths, for circular reference checks
111      *
112      * @return string
113      *
114      * @throws FileImportException
115      */
116     protected function combineImports($source, $content, $parents)
117     {
118         $importRegexes = array(
119             // @import url(xxx)
120             '/
121             # import statement
122             @import
124             # whitespace
125             \s+
127                 # open url()
128                 url\(
130                     # (optional) open path enclosure
131                     (?P<quotes>["\']?)
133                         # fetch path
134                         (?P<path>.+?)
136                     # (optional) close path enclosure
137                     (?P=quotes)
139                 # close url()
140                 \)
142                 # (optional) trailing whitespace
143                 \s*
145                 # (optional) media statement(s)
146                 (?P<media>[^;]*)
148                 # (optional) trailing whitespace
149                 \s*
151             # (optional) closing semi-colon
152             ;?
154             /ix',
156             // @import 'xxx'
157             '/
159             # import statement
160             @import
162             # whitespace
163             \s+
165                 # open path enclosure
166                 (?P<quotes>["\'])
168                     # fetch path
169                     (?P<path>.+?)
171                 # close path enclosure
172                 (?P=quotes)
174                 # (optional) trailing whitespace
175                 \s*
177                 # (optional) media statement(s)
178                 (?P<media>[^;]*)
180                 # (optional) trailing whitespace
181                 \s*
183             # (optional) closing semi-colon
184             ;?
186             /ix',
187         );
189         // find all relative imports in css
190         $matches = array();
191         foreach ($importRegexes as $importRegex) {
192             if (preg_match_all($importRegex, $content, $regexMatches, PREG_SET_ORDER)) {
193                 $matches = array_merge($matches, $regexMatches);
194             }
195         }
197         $search = array();
198         $replace = array();
200         // loop the matches
201         foreach ($matches as $match) {
202             // get the path for the file that will be imported
203             $importPath = dirname($source).'/'.$match['path'];
205             // only replace the import with the content if we can grab the
206             // content of the file
207             if (!$this->canImportByPath($match['path']) || !$this->canImportFile($importPath)) {
208                 continue;
209             }
211             // check if current file was not imported previously in the same
212             // import chain.
213             if (in_array($importPath, $parents)) {
214                 throw new FileImportException('Failed to import file "'.$importPath.'": circular reference detected.');
215             }
217             // grab referenced file & minify it (which may include importing
218             // yet other @import statements recursively)
219             $minifier = new static($importPath);
220             $minifier->setMaxImportSize($this->maxImportSize);
221             $minifier->setImportExtensions($this->importExtensions);
222             $importContent = $minifier->execute($source, $parents);
224             // check if this is only valid for certain media
225             if (!empty($match['media'])) {
226                 $importContent = '@media '.$match['media'].'{'.$importContent.'}';
227             }
229             // add to replacement array
230             $search[] = $match[0];
231             $replace[] = $importContent;
232         }
234         // replace the import statements
235         return str_replace($search, $replace, $content);
236     }
238     /**
239      * Import files into the CSS, base64-ized.
240      *
241      * @url(image.jpg) images will be loaded and their content merged into the
242      * original file, to save HTTP requests.
243      *
244      * @param string $source  The file to import files for
245      * @param string $content The CSS content to import files for
246      *
247      * @return string
248      */
249     protected function importFiles($source, $content)
250     {
251         $regex = '/url\((["\']?)(.+?)\\1\)/i';
252         if ($this->importExtensions && preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) {
253             $search = array();
254             $replace = array();
256             // loop the matches
257             foreach ($matches as $match) {
258                 $extension = substr(strrchr($match[2], '.'), 1);
259                 if ($extension && !array_key_exists($extension, $this->importExtensions)) {
260                     continue;
261                 }
263                 // get the path for the file that will be imported
264                 $path = $match[2];
265                 $path = dirname($source).'/'.$path;
267                 // only replace the import with the content if we're able to get
268                 // the content of the file, and it's relatively small
269                 if ($this->canImportFile($path) && $this->canImportBySize($path)) {
270                     // grab content && base64-ize
271                     $importContent = $this->load($path);
272                     $importContent = base64_encode($importContent);
274                     // build replacement
275                     $search[] = $match[0];
276                     $replace[] = 'url('.$this->importExtensions[$extension].';base64,'.$importContent.')';
277                 }
278             }
280             // replace the import statements
281             $content = str_replace($search, $replace, $content);
282         }
284         return $content;
285     }
287     /**
288      * Minify the data.
289      * Perform CSS optimizations.
290      *
291      * @param string[optional] $path    Path to write the data to
292      * @param string[]         $parents Parent paths, for circular reference checks
293      *
294      * @return string The minified data
295      */
296     public function execute($path = null, $parents = array())
297     {
298         $content = '';
300         // loop CSS data (raw data and files)
301         foreach ($this->data as $source => $css) {
302             /*
303              * Let's first take out strings & comments, since we can't just
304              * remove whitespace anywhere. If whitespace occurs inside a string,
305              * we should leave it alone. E.g.:
306              * p { content: "a   test" }
307              */
308             $this->extractStrings();
309             $this->stripComments();
310             $this->extractCalcs();
311             $css = $this->replace($css);
313             $css = $this->stripWhitespace($css);
314             $css = $this->shortenColors($css);
315             $css = $this->shortenZeroes($css);
316             $css = $this->shortenFontWeights($css);
317             $css = $this->stripEmptyTags($css);
319             // restore the string we've extracted earlier
320             $css = $this->restoreExtractedData($css);
322             $source = is_int($source) ? '' : $source;
323             $parents = $source ? array_merge($parents, array($source)) : $parents;
324             $css = $this->combineImports($source, $css, $parents);
325             $css = $this->importFiles($source, $css);
327             /*
328              * If we'll save to a new path, we'll have to fix the relative paths
329              * to be relative no longer to the source file, but to the new path.
330              * If we don't write to a file, fall back to same path so no
331              * conversion happens (because we still want it to go through most
332              * of the move code, which also addresses url() & @import syntax...)
333              */
334             $converter = $this->getPathConverter($source, $path ?: $source);
335             $css = $this->move($converter, $css);
337             // combine css
338             $content .= $css;
339         }
341         $content = $this->moveImportsToTop($content);
343         return $content;
344     }
346     /**
347      * Moving a css file should update all relative urls.
348      * Relative references (e.g. ../images/image.gif) in a certain css file,
349      * will have to be updated when a file is being saved at another location
350      * (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper).
351      *
352      * @param ConverterInterface $converter Relative path converter
353      * @param string             $content   The CSS content to update relative urls for
354      *
355      * @return string
356      */
357     protected function move(ConverterInterface $converter, $content)
358     {
359         /*
360          * Relative path references will usually be enclosed by url(). @import
361          * is an exception, where url() is not necessary around the path (but is
362          * allowed).
363          * This *could* be 1 regular expression, where both regular expressions
364          * in this array are on different sides of a |. But we're using named
365          * patterns in both regexes, the same name on both regexes. This is only
366          * possible with a (?J) modifier, but that only works after a fairly
367          * recent PCRE version. That's why I'm doing 2 separate regular
368          * expressions & combining the matches after executing of both.
369          */
370         $relativeRegexes = array(
371             // url(xxx)
372             '/
373             # open url()
374             url\(
376                 \s*
378                 # open path enclosure
379                 (?P<quotes>["\'])?
381                     # fetch path
382                     (?P<path>.+?)
384                 # close path enclosure
385                 (?(quotes)(?P=quotes))
387                 \s*
389             # close url()
390             \)
392             /ix',
394             // @import "xxx"
395             '/
396             # import statement
397             @import
399             # whitespace
400             \s+
402                 # we don\'t have to check for @import url(), because the
403                 # condition above will already catch these
405                 # open path enclosure
406                 (?P<quotes>["\'])
408                     # fetch path
409                     (?P<path>.+?)
411                 # close path enclosure
412                 (?P=quotes)
414             /ix',
415         );
417         // find all relative urls in css
418         $matches = array();
419         foreach ($relativeRegexes as $relativeRegex) {
420             if (preg_match_all($relativeRegex, $content, $regexMatches, PREG_SET_ORDER)) {
421                 $matches = array_merge($matches, $regexMatches);
422             }
423         }
425         $search = array();
426         $replace = array();
428         // loop all urls
429         foreach ($matches as $match) {
430             // determine if it's a url() or an @import match
431             $type = (strpos($match[0], '@import') === 0 ? 'import' : 'url');
433             $url = $match['path'];
434             if ($this->canImportByPath($url)) {
435                 // attempting to interpret GET-params makes no sense, so let's discard them for awhile
436                 $params = strrchr($url, '?');
437                 $url = $params ? substr($url, 0, -strlen($params)) : $url;
439                 // fix relative url
440                 $url = $converter->convert($url);
442                 // now that the path has been converted, re-apply GET-params
443                 $url .= $params;
444             }
446             /*
447              * Urls with control characters above 0x7e should be quoted.
448              * According to Mozilla's parser, whitespace is only allowed at the
449              * end of unquoted urls.
450              * Urls with `)` (as could happen with data: uris) should also be
451              * quoted to avoid being confused for the url() closing parentheses.
452              * And urls with a # have also been reported to cause issues.
453              * Urls with quotes inside should also remain escaped.
454              *
455              * @see https://developer.mozilla.org/nl/docs/Web/CSS/url#The_url()_functional_notation
456              * @see https://hg.mozilla.org/mozilla-central/rev/14abca4e7378
457              * @see https://github.com/matthiasmullie/minify/issues/193
458              */
459             $url = trim($url);
460             if (preg_match('/[\s\)\'"#\x{7f}-\x{9f}]/u', $url)) {
461                 $url = $match['quotes'] . $url . $match['quotes'];
462             }
464             // build replacement
465             $search[] = $match[0];
466             if ($type === 'url') {
467                 $replace[] = 'url('.$url.')';
468             } elseif ($type === 'import') {
469                 $replace[] = '@import "'.$url.'"';
470             }
471         }
473         // replace urls
474         return str_replace($search, $replace, $content);
475     }
477     /**
478      * Shorthand hex color codes.
479      * #FF0000 -> #F00.
480      *
481      * @param string $content The CSS content to shorten the hex color codes for
482      *
483      * @return string
484      */
485     protected function shortenColors($content)
486     {
487         $content = preg_replace('/(?<=[: ])#([0-9a-z])\\1([0-9a-z])\\2([0-9a-z])\\3(?:([0-9a-z])\\4)?(?=[; }])/i', '#$1$2$3$4', $content);
489         // remove alpha channel if it's pointless...
490         $content = preg_replace('/(?<=[: ])#([0-9a-z]{6})ff?(?=[; }])/i', '#$1', $content);
491         $content = preg_replace('/(?<=[: ])#([0-9a-z]{3})f?(?=[; }])/i', '#$1', $content);
493         $colors = array(
494             // we can shorten some even more by replacing them with their color name
495             '#F0FFFF' => 'azure',
496             '#F5F5DC' => 'beige',
497             '#A52A2A' => 'brown',
498             '#FF7F50' => 'coral',
499             '#FFD700' => 'gold',
500             '#808080' => 'gray',
501             '#008000' => 'green',
502             '#4B0082' => 'indigo',
503             '#FFFFF0' => 'ivory',
504             '#F0E68C' => 'khaki',
505             '#FAF0E6' => 'linen',
506             '#800000' => 'maroon',
507             '#000080' => 'navy',
508             '#808000' => 'olive',
509             '#CD853F' => 'peru',
510             '#FFC0CB' => 'pink',
511             '#DDA0DD' => 'plum',
512             '#800080' => 'purple',
513             '#F00' => 'red',
514             '#FA8072' => 'salmon',
515             '#A0522D' => 'sienna',
516             '#C0C0C0' => 'silver',
517             '#FFFAFA' => 'snow',
518             '#D2B48C' => 'tan',
519             '#FF6347' => 'tomato',
520             '#EE82EE' => 'violet',
521             '#F5DEB3' => 'wheat',
522             // or the other way around
523             'WHITE' => '#fff',
524             'BLACK' => '#000',
525         );
527         return preg_replace_callback(
528             '/(?<=[: ])('.implode(array_keys($colors), '|').')(?=[; }])/i',
529             function ($match) use ($colors) {
530                 return $colors[strtoupper($match[0])];
531             },
532             $content
533         );
534     }
536     /**
537      * Shorten CSS font weights.
538      *
539      * @param string $content The CSS content to shorten the font weights for
540      *
541      * @return string
542      */
543     protected function shortenFontWeights($content)
544     {
545         $weights = array(
546             'normal' => 400,
547             'bold' => 700,
548         );
550         $callback = function ($match) use ($weights) {
551             return $match[1].$weights[$match[2]];
552         };
554         return preg_replace_callback('/(font-weight\s*:\s*)('.implode('|', array_keys($weights)).')(?=[;}])/', $callback, $content);
555     }
557     /**
558      * Shorthand 0 values to plain 0, instead of e.g. -0em.
559      *
560      * @param string $content The CSS content to shorten the zero values for
561      *
562      * @return string
563      */
564     protected function shortenZeroes($content)
565     {
566         // we don't want to strip units in `calc()` expressions:
567         // `5px - 0px` is valid, but `5px - 0` is not
568         // `10px * 0` is valid (equates to 0), and so is `10 * 0px`, but
569         // `10 * 0` is invalid
570         // we've extracted calcs earlier, so we don't need to worry about this
572         // reusable bits of code throughout these regexes:
573         // before & after are used to make sure we don't match lose unintended
574         // 0-like values (e.g. in #000, or in http://url/1.0)
575         // units can be stripped from 0 values, or used to recognize non 0
576         // values (where wa may be able to strip a .0 suffix)
577         $before = '(?<=[:(, ])';
578         $after = '(?=[ ,);}])';
579         $units = '(em|ex|%|px|cm|mm|in|pt|pc|ch|rem|vh|vw|vmin|vmax|vm)';
581         // strip units after zeroes (0px -> 0)
582         // NOTE: it should be safe to remove all units for a 0 value, but in
583         // practice, Webkit (especially Safari) seems to stumble over at least
584         // 0%, potentially other units as well. Only stripping 'px' for now.
585         // @see https://github.com/matthiasmullie/minify/issues/60
586         $content = preg_replace('/'.$before.'(-?0*(\.0+)?)(?<=0)px'.$after.'/', '\\1', $content);
588         // strip 0-digits (.0 -> 0)
589         $content = preg_replace('/'.$before.'\.0+'.$units.'?'.$after.'/', '0\\1', $content);
590         // strip trailing 0: 50.10 -> 50.1, 50.10px -> 50.1px
591         $content = preg_replace('/'.$before.'(-?[0-9]+\.[0-9]+)0+'.$units.'?'.$after.'/', '\\1\\2', $content);
592         // strip trailing 0: 50.00 -> 50, 50.00px -> 50px
593         $content = preg_replace('/'.$before.'(-?[0-9]+)\.0+'.$units.'?'.$after.'/', '\\1\\2', $content);
594         // strip leading 0: 0.1 -> .1, 01.1 -> 1.1
595         $content = preg_replace('/'.$before.'(-?)0+([0-9]*\.[0-9]+)'.$units.'?'.$after.'/', '\\1\\2\\3', $content);
597         // strip negative zeroes (-0 -> 0) & truncate zeroes (00 -> 0)
598         $content = preg_replace('/'.$before.'-?0+'.$units.'?'.$after.'/', '0\\1', $content);
600         // IE doesn't seem to understand a unitless flex-basis value (correct -
601         // it goes against the spec), so let's add it in again (make it `%`,
602         // which is only 1 char: 0%, 0px, 0 anything, it's all just the same)
603         // @see https://developer.mozilla.org/nl/docs/Web/CSS/flex
604         $content = preg_replace('/flex:([0-9]+\s[0-9]+\s)0([;\}])/', 'flex:${1}0%${2}', $content);
605         $content = preg_replace('/flex-basis:0([;\}])/', 'flex-basis:0%${1}', $content);
607         return $content;
608     }
610     /**
611      * Strip empty tags from source code.
612      *
613      * @param string $content
614      *
615      * @return string
616      */
617     protected function stripEmptyTags($content)
618     {
619         $content = preg_replace('/(?<=^)[^\{\};]+\{\s*\}/', '', $content);
620         $content = preg_replace('/(?<=(\}|;))[^\{\};]+\{\s*\}/', '', $content);
622         return $content;
623     }
625     /**
626      * Strip comments from source code.
627      */
628     protected function stripComments()
629     {
630         // PHP only supports $this inside anonymous functions since 5.4
631         $minifier = $this;
632         $callback = function ($match) use ($minifier) {
633             $count = count($minifier->extracted);
634             $placeholder = '/*'.$count.'*/';
635             $minifier->extracted[$placeholder] = $match[0];
637             return $placeholder;
638         };
639         $this->registerPattern('/\n?\/\*(!|.*?@license|.*?@preserve).*?\*\/\n?/s', $callback);
641         $this->registerPattern('/\/\*.*?\*\//s', '');
642     }
644     /**
645      * Strip whitespace.
646      *
647      * @param string $content The CSS content to strip the whitespace for
648      *
649      * @return string
650      */
651     protected function stripWhitespace($content)
652     {
653         // remove leading & trailing whitespace
654         $content = preg_replace('/^\s*/m', '', $content);
655         $content = preg_replace('/\s*$/m', '', $content);
657         // replace newlines with a single space
658         $content = preg_replace('/\s+/', ' ', $content);
660         // remove whitespace around meta characters
661         // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
662         $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
663         $content = preg_replace('/([\[(:>\+])\s+/', '$1', $content);
664         $content = preg_replace('/\s+([\]\)>\+])/', '$1', $content);
665         $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);
667         // whitespace around + and - can only be stripped inside some pseudo-
668         // classes, like `:nth-child(3+2n)`
669         // not in things like `calc(3px + 2px)`, shorthands like `3px -2px`, or
670         // selectors like `div.weird- p`
671         $pseudos = array('nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type');
672         $content = preg_replace('/:('.implode('|', $pseudos).')\(\s*([+-]?)\s*(.+?)\s*([+-]?)\s*(.*?)\s*\)/', ':$1($2$3$4$5)', $content);
674         // remove semicolon/whitespace followed by closing bracket
675         $content = str_replace(';}', '}', $content);
677         return trim($content);
678     }
680     /**
681      * Replace all `calc()` occurrences.
682      */
683     protected function extractCalcs()
684     {
685         // PHP only supports $this inside anonymous functions since 5.4
686         $minifier = $this;
687         $callback = function ($match) use ($minifier) {
688             $length = strlen($match[1]);
689             $expr = '';
690             $opened = 0;
692             for ($i = 0; $i < $length; $i++) {
693                 $char = $match[1][$i];
694                 $expr .= $char;
695                 if ($char === '(') {
696                     $opened++;
697                 } elseif ($char === ')' && --$opened === 0) {
698                     break;
699                 }
700             }
701             $rest = str_replace($expr, '', $match[1]);
702             $expr = trim(substr($expr, 1, -1));
704             $count = count($minifier->extracted);
705             $placeholder = 'calc('.$count.')';
706             $minifier->extracted[$placeholder] = 'calc('.$expr.')';
708             return $placeholder.$rest;
709         };
711         $this->registerPattern('/calc(\(.+?)(?=$|;|calc\()/', $callback);
712     }
714     /**
715      * Check if file is small enough to be imported.
716      *
717      * @param string $path The path to the file
718      *
719      * @return bool
720      */
721     protected function canImportBySize($path)
722     {
723         return ($size = @filesize($path)) && $size <= $this->maxImportSize * 1024;
724     }
726     /**
727      * Check if file a file can be imported, going by the path.
728      *
729      * @param string $path
730      *
731      * @return bool
732      */
733     protected function canImportByPath($path)
734     {
735         return preg_match('/^(data:|https?:|\\/)/', $path) === 0;
736     }
738     /**
739      * Return a converter to update relative paths to be relative to the new
740      * destination.
741      *
742      * @param string $source
743      * @param string $target
744      *
745      * @return ConverterInterface
746      */
747     protected function getPathConverter($source, $target)
748     {
749         return new Converter($source, $target);
750     }