Completed
Push — master ( 14be91...af0779 )
by Matthias
02:12
created

src/CSS.php (1 issue)

Check for forgotten debug code

Debugging Code Security Critical

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace MatthiasMullie\Minify;
4
5
use MatthiasMullie\Minify\Exceptions\FileImportException;
6
use MatthiasMullie\PathConverter\Converter;
7
8
/**
9
 * CSS minifier.
10
 *
11
 * Please report bugs on https://github.com/matthiasmullie/minify/issues
12
 *
13
 * @author Matthias Mullie <[email protected]>
14
 * @author Tijs Verkoyen <[email protected]>
15
 * @copyright Copyright (c) 2012, Matthias Mullie. All rights reserved
16
 * @license MIT License
17
 */
18
class CSS extends Minify
19
{
20
    /**
21
     * @var int
22
     */
23
    protected $maxImportSize = 5;
24
25
    /**
26
     * @var string[]
27
     */
28
    protected $importExtensions = array(
29
        'gif' => 'data:image/gif',
30
        'png' => 'data:image/png',
31
        'jpe' => 'data:image/jpeg',
32
        'jpg' => 'data:image/jpeg',
33
        'jpeg' => 'data:image/jpeg',
34
        'svg' => 'data:image/svg+xml',
35
        'woff' => 'data:application/x-font-woff',
36
        'tif' => 'image/tiff',
37
        'tiff' => 'image/tiff',
38
        'xbm' => 'image/x-xbitmap',
39
    );
40
41
    /**
42
     * Set the maximum size if files to be imported.
43
     *
44
     * Files larger than this size (in kB) will not be imported into the CSS.
45
     * Importing files into the CSS as data-uri will save you some connections,
46
     * but we should only import relatively small decorative images so that our
47
     * CSS file doesn't get too bulky.
48
     *
49
     * @param int $size Size in kB
50
     */
51
    public function setMaxImportSize($size)
52
    {
53
        $this->maxImportSize = $size;
54
    }
55
56
    /**
57
     * Set the type of extensions to be imported into the CSS (to save network
58
     * connections).
59
     * Keys of the array should be the file extensions & respective values
60
     * should be the data type.
61
     *
62
     * @param string[] $extensions Array of file extensions
63
     */
64
    public function setImportExtensions(array $extensions)
65
    {
66
        $this->importExtensions = $extensions;
67
    }
68
69
    /**
70
     * Move any import statements to the top.
71
     *
72
     * @param string $content Nearly finished CSS content
73
     *
74
     * @return string
75
     */
76
    protected function moveImportsToTop($content)
77
    {
78
        if (preg_match_all('/@import[^;]+;/', $content, $matches)) {
79
            // remove from content
80
            foreach ($matches[0] as $import) {
81
                $content = str_replace($import, '', $content);
82
            }
83
84
            // add to top
85
            $content = implode('', $matches[0]).$content;
86
        }
87
88
        return $content;
89
    }
90
91
    /**
92
     * Combine CSS from import statements.
93
     *
94
     * @import's will be loaded and their content merged into the original file,
95
     * to save HTTP requests.
96
     *
97
     * @param string   $source  The file to combine imports for
98
     * @param string   $content The CSS content to combine imports for
99
     * @param string[] $parents Parent paths, for circular reference checks
100
     *
101
     * @return string
102
     *
103
     * @throws FileImportException
104
     */
105
    protected function combineImports($source, $content, $parents)
106
    {
107
        $importRegexes = array(
108
            // @import url(xxx)
109
            '/
110
            # import statement
111
            @import
112
113
            # whitespace
114
            \s+
115
116
                # open url()
117
                url\(
118
119
                    # (optional) open path enclosure
120
                    (?P<quotes>["\']?)
121
122
                        # fetch path
123
                        (?P<path>.+?)
124
125
                    # (optional) close path enclosure
126
                    (?P=quotes)
127
128
                # close url()
129
                \)
130
131
                # (optional) trailing whitespace
132
                \s*
133
134
                # (optional) media statement(s)
135
                (?P<media>[^;]*)
136
137
                # (optional) trailing whitespace
138
                \s*
139
140
            # (optional) closing semi-colon
141
            ;?
142
143
            /ix',
144
145
            // @import 'xxx'
146
            '/
147
148
            # import statement
149
            @import
150
151
            # whitespace
152
            \s+
153
154
                # open path enclosure
155
                (?P<quotes>["\'])
156
157
                    # fetch path
158
                    (?P<path>.+?)
159
160
                # close path enclosure
161
                (?P=quotes)
162
163
                # (optional) trailing whitespace
164
                \s*
165
166
                # (optional) media statement(s)
167
                (?P<media>[^;]*)
168
169
                # (optional) trailing whitespace
170
                \s*
171
172
            # (optional) closing semi-colon
173
            ;?
174
175
            /ix',
176
        );
177
178
        // find all relative imports in css
179
        $matches = array();
180
        foreach ($importRegexes as $importRegex) {
181
            if (preg_match_all($importRegex, $content, $regexMatches, PREG_SET_ORDER)) {
182
                $matches = array_merge($matches, $regexMatches);
183
            }
184
        }
185
186
        $search = array();
187
        $replace = array();
188
189
        // loop the matches
190
        foreach ($matches as $match) {
191
            // get the path for the file that will be imported
192
            $importPath = dirname($source).'/'.$match['path'];
193
194
            // only replace the import with the content if we can grab the
195
            // content of the file
196
            if (!$this->canImportByPath($match['path']) || !$this->canImportFile($importPath)) {
197
                continue;
198
            }
199
200
            // check if current file was not imported previously in the same
201
            // import chain.
202
            if (in_array($importPath, $parents)) {
203
                throw new FileImportException('Failed to import file "'.$importPath.'": circular reference detected.');
204
            }
205
206
            // grab referenced file & minify it (which may include importing
207
            // yet other @import statements recursively)
208
            $minifier = new static($importPath);
209
            $importContent = $minifier->execute($source, $parents);
210
211
            // check if this is only valid for certain media
212
            if (!empty($match['media'])) {
213
                $importContent = '@media '.$match['media'].'{'.$importContent.'}';
214
            }
215
216
            // add to replacement array
217
            $search[] = $match[0];
218
            $replace[] = $importContent;
219
        }
220
221
        // replace the import statements
222
        return str_replace($search, $replace, $content);
223
    }
224
225
    /**
226
     * Import files into the CSS, base64-ized.
227
     *
228
     * @url(image.jpg) images will be loaded and their content merged into the
229
     * original file, to save HTTP requests.
230
     *
231
     * @param string $source  The file to import files for
232
     * @param string $content The CSS content to import files for
233
     *
234
     * @return string
235
     */
236
    protected function importFiles($source, $content)
237
    {
238
        $extensions = array_keys($this->importExtensions);
239
        $regex = '/url\((["\']?)(.+?\.('.implode('|', $extensions).'))\\1\)/i';
240
        if ($extensions && preg_match_all($regex, $content, $matches, PREG_SET_ORDER)) {
241
            $search = array();
242
            $replace = array();
243
244
            // loop the matches
245
            foreach ($matches as $match) {
246
                // get the path for the file that will be imported
247
                $path = $match[2];
248
                $path = dirname($source).'/'.$path;
249
                $extension = $match[3];
250
251
                // only replace the import with the content if we're able to get
252
                // the content of the file, and it's relatively small
253
var_dump($match[2], $this->canImportByPath($match[2]));
0 ignored issues
show
Security Debugging Code introduced by
var_dump($match[2], $thi...portByPath($match[2])); looks like debug code. Are you sure you do not want to remove it? This might expose sensitive data.
Loading history...
254
                if ($this->canImportByPath($match[2]) && $this->canImportFile($path) && $this->canImportBySize($path)) {
255
                    // grab content && base64-ize
256
                    $importContent = $this->load($path);
257
                    $importContent = base64_encode($importContent);
258
259
                    // build replacement
260
                    $search[] = $match[0];
261
                    $replace[] = 'url('.$this->importExtensions[$extension].';base64,'.$importContent.')';
262
                }
263
            }
264
265
            // replace the import statements
266
            $content = str_replace($search, $replace, $content);
267
        }
268
269
        return $content;
270
    }
271
272
    /**
273
     * Minify the data.
274
     * Perform CSS optimizations.
275
     *
276
     * @param string[optional] $path    Path to write the data to
277
     * @param string[]         $parents Parent paths, for circular reference checks
278
     *
279
     * @return string The minified data
280
     */
281
    public function execute($path = null, $parents = array())
282
    {
283
        $content = '';
284
285
        // loop css data (raw data and files)
286
        foreach ($this->data as $source => $css) {
287
            /*
288
             * Let's first take out strings & comments, since we can't just remove
289
             * whitespace anywhere. If whitespace occurs inside a string, we should
290
             * leave it alone. E.g.:
291
             * p { content: "a   test" }
292
             */
293
            $this->extractStrings();
294
            $this->stripComments();
295
            $css = $this->replace($css);
296
297
            $css = $this->stripWhitespace($css);
298
            $css = $this->shortenHex($css);
299
            $css = $this->shortenZeroes($css);
300
            $css = $this->shortenFontWeights($css);
301
            $css = $this->stripEmptyTags($css);
302
303
            // restore the string we've extracted earlier
304
            $css = $this->restoreExtractedData($css);
305
306
            $source = is_int($source) ? '' : $source;
307
            $parents = $source ? array_merge($parents, array($source)) : $parents;
308
            $css = $this->combineImports($source, $css, $parents);
309
            $css = $this->importFiles($source, $css);
310
311
            /*
312
             * If we'll save to a new path, we'll have to fix the relative paths
313
             * to be relative no longer to the source file, but to the new path.
314
             * If we don't write to a file, fall back to same path so no
315
             * conversion happens (because we still want it to go through most
316
             * of the move code...)
317
             */
318
            $converter = new Converter($source, $path ?: $source);
319
            $css = $this->move($converter, $css);
320
321
            // combine css
322
            $content .= $css;
323
        }
324
325
        $content = $this->moveImportsToTop($content);
326
327
        return $content;
328
    }
329
330
    /**
331
     * Moving a css file should update all relative urls.
332
     * Relative references (e.g. ../images/image.gif) in a certain css file,
333
     * will have to be updated when a file is being saved at another location
334
     * (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper).
335
     *
336
     * @param Converter $converter Relative path converter
337
     * @param string    $content   The CSS content to update relative urls for
338
     *
339
     * @return string
340
     */
341
    protected function move(Converter $converter, $content)
342
    {
343
        /*
344
         * Relative path references will usually be enclosed by url(). @import
345
         * is an exception, where url() is not necessary around the path (but is
346
         * allowed).
347
         * This *could* be 1 regular expression, where both regular expressions
348
         * in this array are on different sides of a |. But we're using named
349
         * patterns in both regexes, the same name on both regexes. This is only
350
         * possible with a (?J) modifier, but that only works after a fairly
351
         * recent PCRE version. That's why I'm doing 2 separate regular
352
         * expressions & combining the matches after executing of both.
353
         */
354
        $relativeRegexes = array(
355
            // url(xxx)
356
            '/
357
            # open url()
358
            url\(
359
360
                \s*
361
362
                # open path enclosure
363
                (?P<quotes>["\'])?
364
365
                    # fetch path
366
                    (?P<path>.+?)
367
368
                # close path enclosure
369
                (?(quotes)(?P=quotes))
370
371
                \s*
372
373
            # close url()
374
            \)
375
376
            /ix',
377
378
            // @import "xxx"
379
            '/
380
            # import statement
381
            @import
382
383
            # whitespace
384
            \s+
385
386
                # we don\'t have to check for @import url(), because the
387
                # condition above will already catch these
388
389
                # open path enclosure
390
                (?P<quotes>["\'])
391
392
                    # fetch path
393
                    (?P<path>.+?)
394
395
                # close path enclosure
396
                (?P=quotes)
397
398
            /ix',
399
        );
400
401
        // find all relative urls in css
402
        $matches = array();
403
        foreach ($relativeRegexes as $relativeRegex) {
404
            if (preg_match_all($relativeRegex, $content, $regexMatches, PREG_SET_ORDER)) {
405
                $matches = array_merge($matches, $regexMatches);
406
            }
407
        }
408
409
        $search = array();
410
        $replace = array();
411
412
        // loop all urls
413
        foreach ($matches as $match) {
414
            // determine if it's a url() or an @import match
415
            $type = (strpos($match[0], '@import') === 0 ? 'import' : 'url');
416
417
            $url = $match['path'];
418
            if ($this->canImportByPath($url)) {
419
                // attempting to interpret GET-params makes no sense, so let's discard them for awhile
420
                $params = strrchr($url, '?');
421
                $url = $params ? substr($url, 0, -strlen($params)) : $url;
422
423
                // fix relative url
424
                $url = $converter->convert($url);
425
426
                // now that the path has been converted, re-apply GET-params
427
                $url .= $params;
428
            }
429
430
            // build replacement
431
            $search[] = $match[0];
432
            if ($type === 'url') {
433
                $replace[] = 'url('.$url.')';
434
            } elseif ($type === 'import') {
435
                $replace[] = '@import "'.$url.'"';
436
            }
437
        }
438
439
        // replace urls
440
        return str_replace($search, $replace, $content);
441
    }
442
443
    /**
444
     * Shorthand hex color codes.
445
     * #FF0000 -> #F00.
446
     *
447
     * @param string $content The CSS content to shorten the hex color codes for
448
     *
449
     * @return string
450
     */
451
    protected function shortenHex($content)
452
    {
453
        $content = preg_replace('/(?<=[: ])#([0-9a-z])\\1([0-9a-z])\\2([0-9a-z])\\3(?=[; }])/i', '#$1$2$3', $content);
454
455
        // we can shorten some even more by replacing them with their color name
456
        $colors = array(
457
            '#F0FFFF' => 'azure',
458
            '#F5F5DC' => 'beige',
459
            '#A52A2A' => 'brown',
460
            '#FF7F50' => 'coral',
461
            '#FFD700' => 'gold',
462
            '#808080' => 'gray',
463
            '#008000' => 'green',
464
            '#4B0082' => 'indigo',
465
            '#FFFFF0' => 'ivory',
466
            '#F0E68C' => 'khaki',
467
            '#FAF0E6' => 'linen',
468
            '#800000' => 'maroon',
469
            '#000080' => 'navy',
470
            '#808000' => 'olive',
471
            '#CD853F' => 'peru',
472
            '#FFC0CB' => 'pink',
473
            '#DDA0DD' => 'plum',
474
            '#800080' => 'purple',
475
            '#F00' => 'red',
476
            '#FA8072' => 'salmon',
477
            '#A0522D' => 'sienna',
478
            '#C0C0C0' => 'silver',
479
            '#FFFAFA' => 'snow',
480
            '#D2B48C' => 'tan',
481
            '#FF6347' => 'tomato',
482
            '#EE82EE' => 'violet',
483
            '#F5DEB3' => 'wheat',
484
        );
485
486
        return preg_replace_callback(
487
            '/(?<=[: ])('.implode(array_keys($colors), '|').')(?=[; }])/i',
488
            function ($match) use ($colors) {
489
                return $colors[strtoupper($match[0])];
490
            },
491
            $content
492
        );
493
    }
494
495
    /**
496
     * Shorten CSS font weights.
497
     *
498
     * @param string $content The CSS content to shorten the font weights for
499
     *
500
     * @return string
501
     */
502
    protected function shortenFontWeights($content)
503
    {
504
        $weights = array(
505
            'normal' => 400,
506
            'bold' => 700,
507
        );
508
509
        $callback = function ($match) use ($weights) {
510
            return $match[1].$weights[$match[2]];
511
        };
512
513
        return preg_replace_callback('/(font-weight\s*:\s*)('.implode('|', array_keys($weights)).')(?=[;}])/', $callback, $content);
514
    }
515
516
    /**
517
     * Shorthand 0 values to plain 0, instead of e.g. -0em.
518
     *
519
     * @param string $content The CSS content to shorten the zero values for
520
     *
521
     * @return string
522
     */
523
    protected function shortenZeroes($content)
524
    {
525
        // reusable bits of code throughout these regexes:
526
        // before & after are used to make sure we don't match lose unintended
527
        // 0-like values (e.g. in #000, or in http://url/1.0)
528
        // units can be stripped from 0 values, or used to recognize non 0
529
        // values (where wa may be able to strip a .0 suffix)
530
        $before = '(?<=[:(, ])';
531
        $after = '(?=[ ,);}])';
532
        $units = '(em|ex|%|px|cm|mm|in|pt|pc|ch|rem|vh|vw|vmin|vmax|vm)';
533
534
        // strip units after zeroes (0px -> 0)
535
        // NOTE: it should be safe to remove all units for a 0 value, but in
536
        // practice, Webkit (especially Safari) seems to stumble over at least
537
        // 0%, potentially other units as well. Only stripping 'px' for now.
538
        // @see https://github.com/matthiasmullie/minify/issues/60
539
        $content = preg_replace('/'.$before.'(-?0*(\.0+)?)(?<=0)px'.$after.'/', '\\1', $content);
540
541
        // strip 0-digits (.0 -> 0)
542
        $content = preg_replace('/'.$before.'\.0+'.$units.'?'.$after.'/', '0\\1', $content);
543
        // strip trailing 0: 50.10 -> 50.1, 50.10px -> 50.1px
544
        $content = preg_replace('/'.$before.'(-?[0-9]+\.[0-9]+)0+'.$units.'?'.$after.'/', '\\1\\2', $content);
545
        // strip trailing 0: 50.00 -> 50, 50.00px -> 50px
546
        $content = preg_replace('/'.$before.'(-?[0-9]+)\.0+'.$units.'?'.$after.'/', '\\1\\2', $content);
547
        // strip leading 0: 0.1 -> .1, 01.1 -> 1.1
548
        $content = preg_replace('/'.$before.'(-?)0+([0-9]*\.[0-9]+)'.$units.'?'.$after.'/', '\\1\\2\\3', $content);
549
550
        // strip negative zeroes (-0 -> 0) & truncate zeroes (00 -> 0)
551
        $content = preg_replace('/'.$before.'-?0+'.$units.'?'.$after.'/', '0\\1', $content);
552
553
        // remove zeroes where they make no sense in calc: e.g. calc(100px - 0)
554
        // the 0 doesn't have any effect, and this isn't even valid without unit
555
        // strip all `+ 0` or `- 0` occurrences: calc(10% + 0) -> calc(10%)
556
        // looped because there may be multiple 0s inside 1 group of parentheses
557
        do {
558
            $previous = $content;
559
            $content = preg_replace('/\(([^\(\)]+)\s+[\+\-]\s+0(\s+[^\(\)]+)?\)/', '(\\1\\2)', $content);
560
        } while ($content !== $previous);
561
        // strip all `0 +` occurrences: calc(0 + 10%) -> calc(10%)
562
        $content = preg_replace('/\(\s*0\s+\+\s+([^\(\)]+)\)/', '(\\1)', $content);
563
        // strip all `0 -` occurrences: calc(0 - 10%) -> calc(-10%)
564
        $content = preg_replace('/\(\s*0\s+\-\s+([^\(\)]+)\)/', '(-\\1)', $content);
565
        // I'm not going to attempt to optimize away `x * 0` instances:
566
        // it's dumb enough code already that it likely won't occur, and it's
567
        // too complex to do right (order of operations would have to be
568
        // respected etc)
569
        // what I cared about most here was fixing incorrectly truncated units
570
571
        return $content;
572
    }
573
574
    /**
575
     * Strip comments from source code.
576
     *
577
     * @param string $content
578
     *
579
     * @return string
580
     */
581
    protected function stripEmptyTags($content)
582
    {
583
        return preg_replace('/(^|\}|;)[^\{\};]+\{\s*\}/', '\\1', $content);
584
    }
585
586
    /**
587
     * Strip comments from source code.
588
     */
589
    protected function stripComments()
590
    {
591
        $this->registerPattern('/\/\*.*?\*\//s', '');
592
    }
593
594
    /**
595
     * Strip whitespace.
596
     *
597
     * @param string $content The CSS content to strip the whitespace for
598
     *
599
     * @return string
600
     */
601
    protected function stripWhitespace($content)
602
    {
603
        // remove leading & trailing whitespace
604
        $content = preg_replace('/^\s*/m', '', $content);
605
        $content = preg_replace('/\s*$/m', '', $content);
606
607
        // replace newlines with a single space
608
        $content = preg_replace('/\s+/', ' ', $content);
609
610
        // remove whitespace around meta characters
611
        // inspired by stackoverflow.com/questions/15195750/minify-compress-css-with-regex
612
        $content = preg_replace('/\s*([\*$~^|]?+=|[{};,>~]|!important\b)\s*/', '$1', $content);
613
        $content = preg_replace('/([\[(:])\s+/', '$1', $content);
614
        $content = preg_replace('/\s+([\]\)])/', '$1', $content);
615
        $content = preg_replace('/\s+(:)(?![^\}]*\{)/', '$1', $content);
616
617
        // whitespace around + and - can only be stripped in selectors, like
618
        // :nth-child(3+2n), not in things like calc(3px + 2px) or shorthands
619
        // like 3px -2px
620
        $content = preg_replace('/\s*([+-])\s*(?=[^}]*{)/', '$1', $content);
621
622
        // remove semicolon/whitespace followed by closing bracket
623
        $content = str_replace(';}', '}', $content);
624
625
        return trim($content);
626
    }
627
628
    /**
629
     * Check if file is small enough to be imported.
630
     *
631
     * @param string $path The path to the file
632
     *
633
     * @return bool
634
     */
635
    protected function canImportBySize($path)
636
    {
637
        return ($size = @filesize($path)) && $size <= $this->maxImportSize * 1024;
638
    }
639
640
    /**
641
     * Check if file a file can be imported, going by the path.
642
     *
643
     * @param string $path
644
     *
645
     * @return bool
646
     */
647
    protected function canImportByPath($path)
648
    {
649
        return preg_match('/^(data:|https?:|\\/)/', $path) === 0;
650
    }
651
}
652