Subversion Repositories oidplus

Rev

Rev 1308 | 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's will be loaded and their content merged into the original file,
  110.      * 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->convertLegacyColors($css);
  320.             $css = $this->cleanupModernColors($css);
  321.             $css = $this->shortenHEXColors($css);
  322.             $css = $this->shortenZeroes($css);
  323.             $css = $this->shortenFontWeights($css);
  324.             $css = $this->stripEmptyTags($css);
  325.  
  326.             // restore the string we've extracted earlier
  327.             $css = $this->restoreExtractedData($css);
  328.  
  329.             $source = is_int($source) ? '' : $source;
  330.             $parents = $source ? array_merge($parents, array($source)) : $parents;
  331.             $css = $this->combineImports($source, $css, $parents);
  332.             $css = $this->importFiles($source, $css);
  333.  
  334.             /*
  335.              * If we'll save to a new path, we'll have to fix the relative paths
  336.              * to be relative no longer to the source file, but to the new path.
  337.              * If we don't write to a file, fall back to same path so no
  338.              * conversion happens (because we still want it to go through most
  339.              * of the move code, which also addresses url() & @import syntax...)
  340.              */
  341.             $converter = $this->getPathConverter($source, $path ?: $source);
  342.             $css = $this->move($converter, $css);
  343.  
  344.             // combine css
  345.             $content .= $css;
  346.         }
  347.  
  348.         $content = $this->moveImportsToTop($content);
  349.  
  350.         return $content;
  351.     }
  352.  
  353.     /**
  354.      * Moving a css file should update all relative urls.
  355.      * Relative references (e.g. ../images/image.gif) in a certain css file,
  356.      * will have to be updated when a file is being saved at another location
  357.      * (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper).
  358.      *
  359.      * @param ConverterInterface $converter Relative path converter
  360.      * @param string             $content   The CSS content to update relative urls for
  361.      *
  362.      * @return string
  363.      */
  364.     protected function move(ConverterInterface $converter, $content)
  365.     {
  366.         /*
  367.          * Relative path references will usually be enclosed by url(). @import
  368.          * is an exception, where url() is not necessary around the path (but is
  369.          * allowed).
  370.          * This *could* be 1 regular expression, where both regular expressions
  371.          * in this array are on different sides of a |. But we're using named
  372.          * patterns in both regexes, the same name on both regexes. This is only
  373.          * possible with a (?J) modifier, but that only works after a fairly
  374.          * recent PCRE version. That's why I'm doing 2 separate regular
  375.          * expressions & combining the matches after executing of both.
  376.          */
  377.         $relativeRegexes = array(
  378.             // url(xxx)
  379.             '/
  380.            # open url()
  381.            url\(
  382.  
  383.                \s*
  384.  
  385.                # open path enclosure
  386.                (?P<quotes>["\'])?
  387.  
  388.                    # fetch path
  389.                    (?P<path>.+?)
  390.  
  391.                # close path enclosure
  392.                (?(quotes)(?P=quotes))
  393.  
  394.                \s*
  395.  
  396.            # close url()
  397.            \)
  398.  
  399.            /ix',
  400.  
  401.             // @import "xxx"
  402.             '/
  403.            # import statement
  404.            @import
  405.  
  406.            # whitespace
  407.            \s+
  408.  
  409.                # we don\'t have to check for @import url(), because the
  410.                # condition above will already catch these
  411.  
  412.                # open path enclosure
  413.                (?P<quotes>["\'])
  414.  
  415.                    # fetch path
  416.                    (?P<path>.+?)
  417.  
  418.                # close path enclosure
  419.                (?P=quotes)
  420.  
  421.            /ix',
  422.         );
  423.  
  424.         // find all relative urls in css
  425.         $matches = array();
  426.         foreach ($relativeRegexes as $relativeRegex) {
  427.             if (preg_match_all($relativeRegex, $content, $regexMatches, PREG_SET_ORDER)) {
  428.                 $matches = array_merge($matches, $regexMatches);
  429.             }
  430.         }
  431.  
  432.         $search = array();
  433.         $replace = array();
  434.  
  435.         // loop all urls
  436.         foreach ($matches as $match) {
  437.             // determine if it's a url() or an @import match
  438.             $type = (strpos($match[0], '@import') === 0 ? 'import' : 'url');
  439.  
  440.             $url = $match['path'];
  441.             if ($this->canImportByPath($url)) {
  442.                 // attempting to interpret GET-params makes no sense, so let's discard them for awhile
  443.                 $params = strrchr($url, '?');
  444.                 $url = $params ? substr($url, 0, -strlen($params)) : $url;
  445.  
  446.                 // fix relative url
  447.                 $url = $converter->convert($url);
  448.  
  449.                 // now that the path has been converted, re-apply GET-params
  450.                 $url .= $params;
  451.             }
  452.  
  453.             /*
  454.              * Urls with control characters above 0x7e should be quoted.
  455.              * According to Mozilla's parser, whitespace is only allowed at the
  456.              * end of unquoted urls.
  457.              * Urls with `)` (as could happen with data: uris) should also be
  458.              * quoted to avoid being confused for the url() closing parentheses.
  459.              * And urls with a # have also been reported to cause issues.
  460.              * Urls with quotes inside should also remain escaped.
  461.              *
  462.              * @see https://developer.mozilla.org/nl/docs/Web/CSS/url#The_url()_functional_notation
  463.              * @see https://hg.mozilla.org/mozilla-central/rev/14abca4e7378
  464.              * @see https://github.com/matthiasmullie/minify/issues/193
  465.              */
  466.             $url = trim($url);
  467.             if (preg_match('/[\s\)\'"#\x{7f}-\x{9f}]/u', $url)) {
  468.                 $url = $match['quotes'] . $url . $match['quotes'];
  469.             }
  470.  
  471.             // build replacement
  472.             $search[] = $match[0];
  473.             if ($type === 'url') {
  474.                 $replace[] = 'url(' . $url . ')';
  475.             } elseif ($type === 'import') {
  476.                 $replace[] = '@import "' . $url . '"';
  477.             }
  478.         }
  479.  
  480.         // replace urls
  481.         return str_replace($search, $replace, $content);
  482.     }
  483.  
  484.     /**
  485.      * Shorthand HEX color codes.
  486.      * #FF0000FF -> #f00 -> red
  487.      * #FF00FF00 -> transparent.
  488.      *
  489.      * @param string $content The CSS content to shorten the HEX color codes for
  490.      *
  491.      * @return string
  492.      */
  493.     protected function shortenHexColors($content)
  494.     {
  495.         // shorten repeating patterns within HEX ..
  496.         $content = preg_replace('/(?<=[: ])#([0-9a-f])\\1([0-9a-f])\\2([0-9a-f])\\3(?:([0-9a-f])\\4)?(?=[; }])/i', '#$1$2$3$4', $content);
  497.  
  498.         // remove alpha channel if it's pointless ..
  499.         $content = preg_replace('/(?<=[: ])#([0-9a-f]{6})ff(?=[; }])/i', '#$1', $content);
  500.         $content = preg_replace('/(?<=[: ])#([0-9a-f]{3})f(?=[; }])/i', '#$1', $content);
  501.  
  502.         // replace `transparent` with shortcut ..
  503.         $content = preg_replace('/(?<=[: ])#[0-9a-f]{6}00(?=[; }])/i', '#fff0', $content);
  504.  
  505.         $colors = array(
  506.             // make these more readable
  507.             '#00f' => 'blue',
  508.             '#dc143c' => 'crimson',
  509.             '#0ff' => 'cyan',
  510.             '#8b0000' => 'darkred',
  511.             '#696969' => 'dimgray',
  512.             '#ff69b4' => 'hotpink',
  513.             '#0f0' => 'lime',
  514.             '#fdf5e6' => 'oldlace',
  515.             '#87ceeb' => 'skyblue',
  516.             '#d8bfd8' => 'thistle',
  517.             // we can shorten some even more by replacing them with their color name
  518.             '#f0ffff' => 'azure',
  519.             '#f5f5dc' => 'beige',
  520.             '#ffe4c4' => 'bisque',
  521.             '#a52a2a' => 'brown',
  522.             '#ff7f50' => 'coral',
  523.             '#ffd700' => 'gold',
  524.             '#808080' => 'gray',
  525.             '#008000' => 'green',
  526.             '#4b0082' => 'indigo',
  527.             '#fffff0' => 'ivory',
  528.             '#f0e68c' => 'khaki',
  529.             '#faf0e6' => 'linen',
  530.             '#800000' => 'maroon',
  531.             '#000080' => 'navy',
  532.             '#808000' => 'olive',
  533.             '#ffa500' => 'orange',
  534.             '#da70d6' => 'orchid',
  535.             '#cd853f' => 'peru',
  536.             '#ffc0cb' => 'pink',
  537.             '#dda0dd' => 'plum',
  538.             '#800080' => 'purple',
  539.             '#f00' => 'red',
  540.             '#fa8072' => 'salmon',
  541.             '#a0522d' => 'sienna',
  542.             '#c0c0c0' => 'silver',
  543.             '#fffafa' => 'snow',
  544.             '#d2b48c' => 'tan',
  545.             '#008080' => 'teal',
  546.             '#ff6347' => 'tomato',
  547.             '#ee82ee' => 'violet',
  548.             '#f5deb3' => 'wheat',
  549.             // or the other way around
  550.             'black' => '#000',
  551.             'fuchsia' => '#f0f',
  552.             'magenta' => '#f0f',
  553.             'white' => '#fff',
  554.             'yellow' => '#ff0',
  555.             // and also `transparent`
  556.             'transparent' => '#fff0',
  557.         );
  558.  
  559.         return preg_replace_callback(
  560.             '/(?<=[: ])(' . implode('|', array_keys($colors)) . ')(?=[; }])/i',
  561.             function ($match) use ($colors) {
  562.                 return $colors[strtolower($match[0])];
  563.             },
  564.             $content
  565.         );
  566.     }
  567.  
  568.     /**
  569.      * Convert RGB|HSL color codes.
  570.      * rgb(255,0,0,.5) -> rgb(255 0 0 / .5).
  571.      * rgb(255,0,0) -> #f00.
  572.      *
  573.      * @param string $content The CSS content to shorten the RGB color codes for
  574.      *
  575.      * @return string
  576.      */
  577.     protected function convertLegacyColors($content)
  578.     {
  579.         /*
  580.           https://drafts.csswg.org/css-color/#color-syntax-legacy
  581.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/rgb
  582.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hsl
  583.         */
  584.  
  585.         // convert legacy color syntax
  586.         $content = preg_replace('/(rgb|hsl)a?\(([^,\s]+)\s*,\s*([^,\s]+)\s*,\s*([^,\s]+)\s*,\s*([^\s\)]+)\)/i', '$1($2 $3 $4 / $5)', $content);
  587.         $content = preg_replace('/(rgb|hsl)a?\(([^,\s]+)\s*,\s*([^,\s]+)\s*,\s*([^,\s]+)\)/i', '$1($2 $3 $4)', $content);
  588.  
  589.         // convert `rgb` to `hex`
  590.         $dec = '([01]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])'; // [000-255] THX @ https://www.regular-expressions.info/numericranges.html
  591.  
  592.         return preg_replace_callback(
  593.             "/rgb\($dec $dec $dec\)/i",
  594.             function ($match) {
  595.                 return sprintf('#%02x%02x%02x', $match[1], $match[2], $match[3]);
  596.             },
  597.             $content
  598.         );
  599.     }
  600.  
  601.     /**
  602.      * Cleanup RGB|HSL|HWB|LCH|LAB
  603.      * rgb(255 0 0 / 1) -> rgb(255 0 0).
  604.      * rgb(255 0 0 / 0) -> transparent.
  605.      *
  606.      * @param string $content The CSS content to cleanup HSL|HWB|LCH|LAB
  607.      *
  608.      * @return string
  609.      */
  610.     protected function cleanupModernColors($content)
  611.     {
  612.         /*
  613.           https://drafts.csswg.org/css-color/#color-syntax-modern
  614.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/hwb
  615.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lch
  616.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/lab
  617.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch
  618.           https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklab
  619.         */
  620.         $tag = '(rgb|hsl|hwb|(?:(?:ok)?(?:lch|lab)))';
  621.  
  622.         // remove alpha channel if it's pointless ..
  623.         $content = preg_replace('/' . $tag . '\(([^\s]+)\s+([^\s]+)\s+([^\s]+)\s+\/\s+1(?:[\.\d]*|00%)?\)/i', '$1($2 $3 $4)', $content);
  624.  
  625.         // replace `transparent` with shortcut ..
  626.         $content = preg_replace('/' . $tag . '\([^\s]+\s+[^\s]+\s+[^\s]+\s+\/\s+0(?:[\.0%]*)?\)/i', '#fff0', $content);
  627.  
  628.         return $content;
  629.     }
  630.  
  631.     /**
  632.      * Shorten CSS font weights.
  633.      *
  634.      * @param string $content The CSS content to shorten the font weights for
  635.      *
  636.      * @return string
  637.      */
  638.     protected function shortenFontWeights($content)
  639.     {
  640.         $weights = array(
  641.             'normal' => 400,
  642.             'bold' => 700,
  643.         );
  644.  
  645.         $callback = function ($match) use ($weights) {
  646.             return $match[1] . $weights[$match[2]];
  647.         };
  648.  
  649.         return preg_replace_callback('/(font-weight\s*:\s*)(' . implode('|', array_keys($weights)) . ')(?=[;}])/', $callback, $content);
  650.     }
  651.  
  652.     /**
  653.      * Shorthand 0 values to plain 0, instead of e.g. -0em.
  654.      *
  655.      * @param string $content The CSS content to shorten the zero values for
  656.      *
  657.      * @return string
  658.      */
  659.     protected function shortenZeroes($content)
  660.     {
  661.         // we don't want to strip units in `calc()` expressions:
  662.         // `5px - 0px` is valid, but `5px - 0` is not
  663.         // `10px * 0` is valid (equates to 0), and so is `10 * 0px`, but
  664.         // `10 * 0` is invalid
  665.         // we've extracted calcs earlier, so we don't need to worry about this
  666.  
  667.         // reusable bits of code throughout these regexes:
  668.         // before & after are used to make sure we don't match lose unintended
  669.         // 0-like values (e.g. in #000, or in http://url/1.0)
  670.         // units can be stripped from 0 values, or used to recognize non 0
  671.         // values (where wa may be able to strip a .0 suffix)
  672.         $before = '(?<=[:(, ])';
  673.         $after = '(?=[ ,);}])';
  674.         $units = '(em|ex|%|px|cm|mm|in|pt|pc|ch|rem|vh|vw|vmin|vmax|vm)';
  675.  
  676.         // strip units after zeroes (0px -> 0)
  677.         // NOTE: it should be safe to remove all units for a 0 value, but in
  678.         // practice, Webkit (especially Safari) seems to stumble over at least
  679.         // 0%, potentially other units as well. Only stripping 'px' for now.
  680.         // @see https://github.com/matthiasmullie/minify/issues/60
  681.         $content = preg_replace('/' . $before . '(-?0*(\.0+)?)(?<=0)px' . $after . '/', '\\1', $content);
  682.  
  683.         // strip 0-digits (.0 -> 0)
  684.         $content = preg_replace('/' . $before . '\.0+' . $units . '?' . $after . '/', '0\\1', $content);
  685.         // strip trailing 0: 50.10 -> 50.1, 50.10px -> 50.1px
  686.         $content = preg_replace('/' . $before . '(-?[0-9]+\.[0-9]+)0+' . $units . '?' . $after . '/', '\\1\\2', $content);
  687.         // strip trailing 0: 50.00 -> 50, 50.00px -> 50px
  688.         $content = preg_replace('/' . $before . '(-?[0-9]+)\.0+' . $units . '?' . $after . '/', '\\1\\2', $content);
  689.         // strip leading 0: 0.1 -> .1, 01.1 -> 1.1
  690.         $content = preg_replace('/' . $before . '(-?)0+([0-9]*\.[0-9]+)' . $units . '?' . $after . '/', '\\1\\2\\3', $content);
  691.  
  692.         // strip negative zeroes (-0 -> 0) & truncate zeroes (00 -> 0)
  693.         $content = preg_replace('/' . $before . '-?0+' . $units . '?' . $after . '/', '0\\1', $content);
  694.  
  695.         // IE doesn't seem to understand a unitless flex-basis value (correct -
  696.         // it goes against the spec), so let's add it in again (make it `%`,
  697.         // which is only 1 char: 0%, 0px, 0 anything, it's all just the same)
  698.         // @see https://developer.mozilla.org/nl/docs/Web/CSS/flex
  699.         $content = preg_replace('/flex:([0-9]+\s[0-9]+\s)0([;\}])/', 'flex:${1}0%${2}', $content);
  700.         $content = preg_replace('/flex-basis:0([;\}])/', 'flex-basis:0%${1}', $content);
  701.  
  702.         return $content;
  703.     }
  704.  
  705.     /**
  706.      * Strip empty tags from source code.
  707.      *
  708.      * @param string $content
  709.      *
  710.      * @return string
  711.      */
  712.     protected function stripEmptyTags($content)
  713.     {
  714.         $content = preg_replace('/(?<=^)[^\{\};]+\{\s*\}/', '', $content);
  715.         $content = preg_replace('/(?<=(\}|;))[^\{\};]+\{\s*\}/', '', $content);
  716.  
  717.         return $content;
  718.     }
  719.  
  720.     /**
  721.      * Strip comments from source code.
  722.      */
  723.     protected function stripComments()
  724.     {
  725.         $this->stripMultilineComments();
  726.     }
  727.  
  728.     /**
  729.      * Strip whitespace.
  730.      *
  731.      * @param string $content The CSS content to strip the whitespace for
  732.      *
  733.      * @return string
  734.      */
  735.     protected function stripWhitespace($content)
  736.     {
  737.         // remove leading & trailing whitespace
  738.         $content = preg_replace('/^\s*/m', '', $content);
  739.         $content = preg_replace('/\s*$/m', '', $content);
  740.  
  741.         // replace newlines with a single space
  742.         $content = preg_replace('/\s+/', ' ', $content);
  743.  
  744.         // remove whitespace around meta characters
  745.         // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
  746.         $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
  747.         $content = preg_replace('/([\[(:>\+])\s+/', '$1', $content);
  748.         $content = preg_replace('/\s+([\]\)>\+])/', '$1', $content);
  749.         $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);
  750.  
  751.         // whitespace around + and - can only be stripped inside some pseudo-
  752.         // classes, like `:nth-child(3+2n)`
  753.         // not in things like `calc(3px + 2px)`, shorthands like `3px -2px`, or
  754.         // selectors like `div.weird- p`
  755.         $pseudos = array('nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type');
  756.         $content = preg_replace('/:(' . implode('|', $pseudos) . ')\(\s*([+-]?)\s*(.+?)\s*([+-]?)\s*(.*?)\s*\)/', ':$1($2$3$4$5)', $content);
  757.  
  758.         // remove semicolon/whitespace followed by closing bracket
  759.         $content = str_replace(';}', '}', $content);
  760.  
  761.         return trim($content);
  762.     }
  763.  
  764.     /**
  765.      * Replace all occurrences of functions that may contain math, where
  766.      * whitespace around operators needs to be preserved (e.g. calc, clamp).
  767.      */
  768.     protected function extractMath()
  769.     {
  770.         $functions = array('calc', 'clamp', 'min', 'max');
  771.         $pattern = '/\b(' . implode('|', $functions) . ')(\(.+?)(?=$|;|})/m';
  772.  
  773.         // PHP only supports $this inside anonymous functions since 5.4
  774.         $minifier = $this;
  775.         $callback = function ($match) use ($minifier, $pattern, &$callback) {
  776.             $function = $match[1];
  777.             $length = strlen($match[2]);
  778.             $expr = '';
  779.             $opened = 0;
  780.  
  781.             // the regular expression for extracting math has 1 significant problem:
  782.             // it can't determine the correct closing parenthesis...
  783.             // instead, it'll match a larger portion of code to where it's certain that
  784.             // the calc() musts have ended, and we'll figure out which is the correct
  785.             // closing parenthesis here, by counting how many have opened
  786.             for ($i = 0; $i < $length; ++$i) {
  787.                 $char = $match[2][$i];
  788.                 $expr .= $char;
  789.                 if ($char === '(') {
  790.                     ++$opened;
  791.                 } elseif ($char === ')' && --$opened === 0) {
  792.                     break;
  793.                 }
  794.             }
  795.  
  796.             // now that we've figured out where the calc() starts and ends, extract it
  797.             $count = count($minifier->extracted);
  798.             $placeholder = 'math(' . $count . ')';
  799.             $minifier->extracted[$placeholder] = $function . '(' . trim(substr($expr, 1, -1)) . ')';
  800.  
  801.             // and since we've captured more code than required, we may have some leftover
  802.             // calc() in here too - go recursive on the remaining but of code to go figure
  803.             // that out and extract what is needed
  804.             $rest = $minifier->str_replace_first($function . $expr, '', $match[0]);
  805.             $rest = preg_replace_callback($pattern, $callback, $rest);
  806.  
  807.             return $placeholder . $rest;
  808.         };
  809.  
  810.         $this->registerPattern($pattern, $callback);
  811.     }
  812.  
  813.     /**
  814.      * Replace custom properties, whose values may be used in scenarios where
  815.      * we wouldn't want them to be minified (e.g. inside calc).
  816.      */
  817.     protected function extractCustomProperties()
  818.     {
  819.         // PHP only supports $this inside anonymous functions since 5.4
  820.         $minifier = $this;
  821.         $this->registerPattern(
  822.             '/(?<=^|[;}{])\s*(--[^:;{}"\'\s]+)\s*:([^;{}]+)/m',
  823.             function ($match) use ($minifier) {
  824.                 $placeholder = '--custom-' . count($minifier->extracted) . ':0';
  825.                 $minifier->extracted[$placeholder] = $match[1] . ':' . trim($match[2]);
  826.  
  827.                 return $placeholder;
  828.             }
  829.         );
  830.     }
  831.  
  832.     /**
  833.      * Check if file is small enough to be imported.
  834.      *
  835.      * @param string $path The path to the file
  836.      *
  837.      * @return bool
  838.      */
  839.     protected function canImportBySize($path)
  840.     {
  841.         return ($size = @filesize($path)) && $size <= $this->maxImportSize * 1024;
  842.     }
  843.  
  844.     /**
  845.      * Check if file a file can be imported, going by the path.
  846.      *
  847.      * @param string $path
  848.      *
  849.      * @return bool
  850.      */
  851.     protected function canImportByPath($path)
  852.     {
  853.         return preg_match('/^(data:|https?:|\\/)/', $path) === 0;
  854.     }
  855.  
  856.     /**
  857.      * Return a converter to update relative paths to be relative to the new
  858.      * destination.
  859.      *
  860.      * @param string $source
  861.      * @param string $target
  862.      *
  863.      * @return ConverterInterface
  864.      */
  865.     protected function getPathConverter($source, $target)
  866.     {
  867.         return new Converter($source, $target);
  868.     }
  869. }
  870.