Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   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   */
  11  
  12  namespace MatthiasMullie\Minify;
  13  
  14  use MatthiasMullie\Minify\Exceptions\FileImportException;
  15  use MatthiasMullie\PathConverter\ConverterInterface;
  16  use MatthiasMullie\PathConverter\Converter;
  17  
  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;
  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          'tif' => 'image/tiff',
  48          'tiff' => 'image/tiff',
  49          'xbm' => 'image/x-xbitmap',
  50      );
  51  
  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      }
  66  
  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      }
  79  
  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              }
  94  
  95              // add to top
  96              $content = implode(';', $matches[2]).';'.trim($content, ';');
  97          }
  98  
  99          return $content;
 100      }
 101  
 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
 123  
 124              # whitespace
 125              \s+
 126  
 127                  # open url()
 128                  url\(
 129  
 130                      # (optional) open path enclosure
 131                      (?P<quotes>["\']?)
 132  
 133                          # fetch path
 134                          (?P<path>.+?)
 135  
 136                      # (optional) close path enclosure
 137                      (?P=quotes)
 138  
 139                  # close url()
 140                  \)
 141  
 142                  # (optional) trailing whitespace
 143                  \s*
 144  
 145                  # (optional) media statement(s)
 146                  (?P<media>[^;]*)
 147  
 148                  # (optional) trailing whitespace
 149                  \s*
 150  
 151              # (optional) closing semi-colon
 152              ;?
 153  
 154              /ix',
 155  
 156              // @import 'xxx'
 157              '/
 158  
 159              # import statement
 160              @import
 161  
 162              # whitespace
 163              \s+
 164  
 165                  # open path enclosure
 166                  (?P<quotes>["\'])
 167  
 168                      # fetch path
 169                      (?P<path>.+?)
 170  
 171                  # close path enclosure
 172                  (?P=quotes)
 173  
 174                  # (optional) trailing whitespace
 175                  \s*
 176  
 177                  # (optional) media statement(s)
 178                  (?P<media>[^;]*)
 179  
 180                  # (optional) trailing whitespace
 181                  \s*
 182  
 183              # (optional) closing semi-colon
 184              ;?
 185  
 186              /ix',
 187          );
 188  
 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          }
 196  
 197          $search = array();
 198          $replace = array();
 199  
 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'];
 204  
 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              }
 210  
 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              }
 216  
 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);
 223  
 224              // check if this is only valid for certain media
 225              if (!empty($match['media'])) {
 226                  $importContent = '@media '.$match['media'].'{'.$importContent.'}';
 227              }
 228  
 229              // add to replacement array
 230              $search[] = $match[0];
 231              $replace[] = $importContent;
 232          }
 233  
 234          // replace the import statements
 235          return str_replace($search, $replace, $content);
 236      }
 237  
 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();
 255  
 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                  }
 262  
 263                  // get the path for the file that will be imported
 264                  $path = $match[2];
 265                  $path = dirname($source).'/'.$path;
 266  
 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);
 273  
 274                      // build replacement
 275                      $search[] = $match[0];
 276                      $replace[] = 'url('.$this->importExtensions[$extension].';base64,'.$importContent.')';
 277                  }
 278              }
 279  
 280              // replace the import statements
 281              $content = str_replace($search, $replace, $content);
 282          }
 283  
 284          return $content;
 285      }
 286  
 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 = '';
 299  
 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);
 312  
 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);
 318  
 319              // restore the string we've extracted earlier
 320              $css = $this->restoreExtractedData($css);
 321  
 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);
 326  
 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);
 336  
 337              // combine css
 338              $content .= $css;
 339          }
 340  
 341          $content = $this->moveImportsToTop($content);
 342  
 343          return $content;
 344      }
 345  
 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\(
 375  
 376                  \s*
 377  
 378                  # open path enclosure
 379                  (?P<quotes>["\'])?
 380  
 381                      # fetch path
 382                      (?P<path>.+?)
 383  
 384                  # close path enclosure
 385                  (?(quotes)(?P=quotes))
 386  
 387                  \s*
 388  
 389              # close url()
 390              \)
 391  
 392              /ix',
 393  
 394              // @import "xxx"
 395              '/
 396              # import statement
 397              @import
 398  
 399              # whitespace
 400              \s+
 401  
 402                  # we don\'t have to check for @import url(), because the
 403                  # condition above will already catch these
 404  
 405                  # open path enclosure
 406                  (?P<quotes>["\'])
 407  
 408                      # fetch path
 409                      (?P<path>.+?)
 410  
 411                  # close path enclosure
 412                  (?P=quotes)
 413  
 414              /ix',
 415          );
 416  
 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          }
 424  
 425          $search = array();
 426          $replace = array();
 427  
 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');
 432  
 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;
 438  
 439                  // fix relative url
 440                  $url = $converter->convert($url);
 441  
 442                  // now that the path has been converted, re-apply GET-params
 443                  $url .= $params;
 444              }
 445  
 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              }
 463  
 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          }
 472  
 473          // replace urls
 474          return str_replace($search, $replace, $content);
 475      }
 476  
 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);
 488  
 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);
 492  
 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          );
 526  
 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      }
 535  
 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          );
 549  
 550          $callback = function ($match) use ($weights) {
 551              return $match[1].$weights[$match[2]];
 552          };
 553  
 554          return preg_replace_callback('/(font-weight\s*:\s*)('.implode('|', array_keys($weights)).')(?=[;}])/', $callback, $content);
 555      }
 556  
 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
 571  
 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)';
 580  
 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);
 587  
 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);
 596  
 597          // strip negative zeroes (-0 -> 0) & truncate zeroes (00 -> 0)
 598          $content = preg_replace('/'.$before.'-?0+'.$units.'?'.$after.'/', '0\\1', $content);
 599  
 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);
 606  
 607          return $content;
 608      }
 609  
 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);
 621  
 622          return $content;
 623      }
 624  
 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];
 636  
 637              return $placeholder;
 638          };
 639          // Moodle-specific change MDL-68191 starts.
 640          /* This was the old code:
 641          $this->registerPattern('/\n?\/\*(!|.*?@license|.*?@preserve).*?\*\/\n?/s', $callback);
 642          */
 643          // This is the new, more accurate and faster regex.
 644          $this->registerPattern('/
 645              # optional newline
 646              \n?
 647  
 648              # start comment
 649              \/\*
 650  
 651              # comment content
 652              (?:
 653                  # either starts with an !
 654                  !
 655              |
 656                  # or, after some number of characters which do not end the comment
 657                  (?:(?!\*\/).)*?
 658  
 659                  # there is either a @license or @preserve tag
 660                  @(?:license|preserve)
 661              )
 662  
 663              # then match to the end of the comment
 664              .*?\*\/\n?
 665  
 666              /ixs', $callback);
 667          // Moodle-specific change MDL-68191.
 668  
 669          $this->registerPattern('/\/\*.*?\*\//s', '');
 670      }
 671  
 672      /**
 673       * Strip whitespace.
 674       *
 675       * @param string $content The CSS content to strip the whitespace for
 676       *
 677       * @return string
 678       */
 679      protected function stripWhitespace($content)
 680      {
 681          // remove leading & trailing whitespace
 682          $content = preg_replace('/^\s*/m', '', $content);
 683          $content = preg_replace('/\s*$/m', '', $content);
 684  
 685          // replace newlines with a single space
 686          $content = preg_replace('/\s+/', ' ', $content);
 687  
 688          // remove whitespace around meta characters
 689          // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
 690          $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
 691          $content = preg_replace('/([\[(:>\+])\s+/', '$1', $content);
 692          $content = preg_replace('/\s+([\]\)>\+])/', '$1', $content);
 693          $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);
 694  
 695          // whitespace around + and - can only be stripped inside some pseudo-
 696          // classes, like `:nth-child(3+2n)`
 697          // not in things like `calc(3px + 2px)`, shorthands like `3px -2px`, or
 698          // selectors like `div.weird- p`
 699          $pseudos = array('nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type');
 700          $content = preg_replace('/:('.implode('|', $pseudos).')\(\s*([+-]?)\s*(.+?)\s*([+-]?)\s*(.*?)\s*\)/', ':$1($2$3$4$5)', $content);
 701  
 702          // remove semicolon/whitespace followed by closing bracket
 703          $content = str_replace(';}', '}', $content);
 704  
 705          return trim($content);
 706      }
 707  
 708      /**
 709       * Replace all `calc()` occurrences.
 710       */
 711      protected function extractCalcs()
 712      {
 713          // PHP only supports $this inside anonymous functions since 5.4
 714          $minifier = $this;
 715          $callback = function ($match) use ($minifier) {
 716              $length = strlen($match[1]);
 717              $expr = '';
 718              $opened = 0;
 719  
 720              for ($i = 0; $i < $length; $i++) {
 721                  $char = $match[1][$i];
 722                  $expr .= $char;
 723                  if ($char === '(') {
 724                      $opened++;
 725                  } elseif ($char === ')' && --$opened === 0) {
 726                      break;
 727                  }
 728              }
 729              $rest = str_replace($expr, '', $match[1]);
 730              $expr = trim(substr($expr, 1, -1));
 731  
 732              $count = count($minifier->extracted);
 733              $placeholder = 'calc('.$count.')';
 734              $minifier->extracted[$placeholder] = 'calc('.$expr.')';
 735  
 736              return $placeholder.$rest;
 737          };
 738  
 739          $this->registerPattern('/calc(\(.+?)(?=$|;|}|calc\()/', $callback);
 740          $this->registerPattern('/calc(\(.+?)(?=$|;|}|calc\()/m', $callback);
 741      }
 742  
 743      /**
 744       * Check if file is small enough to be imported.
 745       *
 746       * @param string $path The path to the file
 747       *
 748       * @return bool
 749       */
 750      protected function canImportBySize($path)
 751      {
 752          return ($size = @filesize($path)) && $size <= $this->maxImportSize * 1024;
 753      }
 754  
 755      /**
 756       * Check if file a file can be imported, going by the path.
 757       *
 758       * @param string $path
 759       *
 760       * @return bool
 761       */
 762      protected function canImportByPath($path)
 763      {
 764          return preg_match('/^(data:|https?:|\\/)/', $path) === 0;
 765      }
 766  
 767      /**
 768       * Return a converter to update relative paths to be relative to the new
 769       * destination.
 770       *
 771       * @param string $source
 772       * @param string $target
 773       *
 774       * @return ConverterInterface
 775       */
 776      protected function getPathConverter($source, $target)
 777      {
 778          return new Converter($source, $target);
 779      }
 780  }