Subversion Repositories oidplus

Rev

Rev 1042 | Rev 1469 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | RSS feed

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