Completed
Push — master ( 98ed6e...314766 )
by Greg
02:23
created

src/Task/Assets/ImageMinify.php (2 issues)

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 Robo\Task\Assets;
4
5
use Robo\Result;
6
use Robo\Exception\TaskException;
7
use Robo\Task\BaseTask;
8
use Robo\Task\Base\Exec;
9
use Symfony\Component\Finder\Finder;
10
use Symfony\Component\Filesystem\Filesystem as sfFilesystem;
11
12
/**
13
 * Minifies images. When the required minifier is not installed on the system
14
 * the task will try to download it from the [imagemin](https://github.com/imagemin) repository.
15
 *
16
 * When the task is run without any specified minifier it will compress the images
17
 * based on the extension.
18
 *
19
 * ```php
20
 * $this->taskImageMinify('assets/images/*')
21
 *     ->to('dist/images/')
22
 *     ->run();
23
 * ```
24
 *
25
 * This will use the following minifiers:
26
 *
27
 * - PNG: optipng
28
 * - GIF: gifsicle
29
 * - JPG, JPEG: jpegtran
30
 * - SVG: svgo
31
 *
32
 * When the minifier is specified the task will use that for all the input files. In that case
33
 * it is useful to filter the files with the extension:
34
 *
35
 * ```php
36
 * $this->taskImageMinify('assets/images/*.png')
37
 *     ->to('dist/images/')
38
 *     ->minifier('pngcrush');
39
 *     ->run();
40
 * ```
41
 *
42
 * The task supports the following minifiers:
43
 *
44
 * - optipng
45
 * - pngquant
46
 * - advpng
47
 * - pngout
48
 * - zopflipng
49
 * - pngcrush
50
 * - gifsicle
51
 * - jpegoptim
52
 * - jpeg-recompress
53
 * - jpegtran
54
 * - svgo (only minification, no downloading)
55
 *
56
 * You can also specifiy extra options for the minifiers:
57
 *
58
 * ```php
59
 * $this->taskImageMinify('assets/images/*.jpg')
60
 *     ->to('dist/images/')
61
 *     ->minifier('jpegtran', ['-progressive' => null, '-copy' => 'none'])
62
 *     ->run();
63
 * ```
64
 *
65
 * This will execute as:
66
 * `jpegtran -copy none -progressive -optimize -outfile "dist/images/test.jpg" "/var/www/test/assets/images/test.jpg"`
67
 */
68
class ImageMinify extends BaseTask
69
{
70
    /**
71
     * Destination directory for the minified images.
72
     *
73
     * @var string
74
     */
75
    protected $to;
76
77
    /**
78
     * Array of the source files.
79
     *
80
     * @var array
81
     */
82
    protected $dirs = [];
83
84
    /**
85
     * Symfony 2 filesystem.
86
     *
87
     * @var sfFilesystem
88
     */
89
    protected $fs;
90
91
    /**
92
     * Target directory for the downloaded binary executables.
93
     *
94
     * @var string
95
     */
96
    protected $executableTargetDir;
97
98
    /**
99
     * Array for the downloaded binary executables.
100
     *
101
     * @var array
102
     */
103
    protected $executablePaths = [];
104
105
    /**
106
     * Array for the individual results of all the files.
107
     *
108
     * @var array
109
     */
110
    protected $results = [];
111
112
    /**
113
     * Default minifier to use.
114
     *
115
     * @var string
116
     */
117
    protected $minifier;
118
119
    /**
120
     * Array for minifier options.
121
     *
122
     * @var array
123
     */
124
    protected $minifierOptions = [];
125
126
    /**
127
     * Supported minifiers.
128
     *
129
     * @var array
130
     */
131
    protected $minifiers = [
132
        // Default 4
133
        'optipng',
134
        'gifsicle',
135
        'jpegtran',
136
        'svgo',
137
        // PNG
138
        'pngquant',
139
        'advpng',
140
        'pngout',
141
        'zopflipng',
142
        'pngcrush',
143
        // JPG
144
        'jpegoptim',
145
        'jpeg-recompress',
146
    ];
147
148
    /**
149
     * Binary repositories of Imagemin.
150
     *
151
     * @link https://github.com/imagemin
152
     *
153
     * @var array
154
     */
155
    protected $imageminRepos = [
156
        // PNG
157
        'optipng' => 'https://github.com/imagemin/optipng-bin',
158
        'pngquant' => 'https://github.com/imagemin/pngquant-bin',
159
        'advpng' => 'https://github.com/imagemin/advpng-bin',
160
        'pngout' => 'https://github.com/imagemin/pngout-bin',
161
        'zopflipng' => 'https://github.com/imagemin/zopflipng-bin',
162
        'pngcrush' => 'https://github.com/imagemin/pngcrush-bin',
163
        // Gif
164
        'gifsicle' => 'https://github.com/imagemin/gifsicle-bin',
165
        // JPG
166
        'jpegtran' => 'https://github.com/imagemin/jpegtran-bin',
167
        'jpegoptim' => 'https://github.com/imagemin/jpegoptim-bin',
168
        'cjpeg' => 'https://github.com/imagemin/mozjpeg-bin', // note: we do not support this minifier because it creates JPG from non-JPG files
169
        'jpeg-recompress' => 'https://github.com/imagemin/jpeg-recompress-bin',
170
        // WebP
171
        'cwebp' => 'https://github.com/imagemin/cwebp-bin', // note: we do not support this minifier because it creates WebP from non-WebP files
172
    ];
173
174
    public function __construct($dirs)
175
    {
176
        is_array($dirs)
177
            ? $this->dirs = $dirs
178
            : $this->dirs[] = $dirs;
179
180
        $this->fs = new sfFilesystem();
181
182
        // guess the best path for the executables based on __DIR__
183
        if (($pos = strpos(__DIR__, 'consolidation/robo')) !== false) {
184
            // the executables should be stored in vendor/bin
185
            $this->executableTargetDir = substr(__DIR__, 0, $pos).'bin';
186
        }
187
188
        // check if the executables are already available
189
        foreach ($this->imageminRepos as $exec => $url) {
190
            $path = $this->executableTargetDir.'/'.$exec;
191
            // if this is Windows add a .exe extension
192
            if (substr($this->getOS(), 0, 3) == 'win') {
193
                $path .= '.exe';
194
            }
195
            if (is_file($path)) {
196
                $this->executablePaths[$exec] = $path;
197
            }
198
        }
199
    }
200
201
    /**
202
     * {@inheritdoc}
203
     */
204
    public function run()
205
    {
206
        // find the files
207
        $files = $this->findFiles($this->dirs);
208
209
        // minify the files
210
        $result = $this->minify($files);
211
        // check if there was an error
212
        if ($result instanceof Result) {
213
            return $result;
214
        }
215
216
        $amount = (count($files) == 1 ? 'image' : 'images');
217
        $message = "Minified {filecount} out of {filetotal} $amount into {destination}";
218
        $context = ['filecount' => count($this->results['success']), 'filetotal' => count($files), 'destination' => $this->to];
219
220
        if (count($this->results['success']) == count($files)) {
221
            $this->printTaskSuccess($message, $context);
222
223
            return Result::success($this, $message, $context);
224
        } else {
225
            return Result::error($this, $message, $context);
226
        }
227
    }
228
229
    /**
230
     * Sets the target directory where the files will be copied to.
231
     *
232
     * @param string $target
233
     *
234
     * @return $this
235
     */
236
    public function to($target)
237
    {
238
        $this->to = rtrim($target, '/');
239
240
        return $this;
241
    }
242
243
    /**
244
     * Sets the minifier.
245
     *
246
     * @param string $minifier
247
     * @param array  $options
248
     *
249
     * @return $this
250
     */
251
    public function minifier($minifier, array $options = [])
252
    {
253
        $this->minifier = $minifier;
254
        $this->minifierOptions = array_merge($this->minifierOptions, $options);
255
256
        return $this;
257
    }
258
259
    /**
260
     * @param array $dirs
261
     *
262
     * @return array|\Robo\Result
263
     *
264
     * @throws \Robo\Exception\TaskException
265
     */
266 View Code Duplication
    protected function findFiles($dirs)
267
    {
268
        $files = array();
269
270
        // find the files
271
        foreach ($dirs as $k => $v) {
272
            // reset finder
273
            $finder = new Finder();
274
275
            $dir = $k;
276
            $to = $v;
277
            // check if target was given with the to() method instead of key/value pairs
278
            if (is_int($k)) {
279
                $dir = $v;
280
                if (isset($this->to)) {
281
                    $to = $this->to;
282
                } else {
283
                    throw new TaskException($this, 'target directory is not defined');
284
                }
285
            }
286
287
            try {
288
                $finder->files()->in($dir);
289
            } catch (\InvalidArgumentException $e) {
290
                // if finder cannot handle it, try with in()->name()
291
                if (strpos($dir, '/') === false) {
292
                    $dir = './'.$dir;
293
                }
294
                $parts = explode('/', $dir);
295
                $new_dir = implode('/', array_slice($parts, 0, -1));
296
                try {
297
                    $finder->files()->in($new_dir)->name(array_pop($parts));
298
                } catch (\InvalidArgumentException $e) {
299
                    return Result::fromException($this, $e);
300
                }
301
            }
302
303
            foreach ($finder as $file) {
304
                // store the absolute path as key and target as value in the files array
305
                $files[$file->getRealpath()] = $this->getTarget($file->getRealPath(), $to);
306
            }
307
            $fileNoun = count($finder) == 1 ? ' file' : ' files';
308
            $this->printTaskInfo("Found {filecount} $fileNoun in {dir}", ['filecount' => count($finder), 'dir' => $dir]);
309
        }
310
311
        return $files;
312
    }
313
314
    /**
315
     * @param string $file
316
     * @param string $to
317
     *
318
     * @return string
319
     */
320
    protected function getTarget($file, $to)
321
    {
322
        $target = $to.'/'.basename($file);
323
324
        return $target;
325
    }
326
327
    /**
328
     * @param array $files
329
     *
330
     * @return \Robo\Result
331
     */
332
    protected function minify($files)
333
    {
334
        // store the individual results into the results array
335
        $this->results = [
336
            'success' => [],
337
            'error' => [],
338
        ];
339
340
        // loop through the files
341
        foreach ($files as $from => $to) {
342
            if (!isset($this->minifier)) {
343
                // check filetype based on the extension
344
                $extension = strtolower(pathinfo($from, PATHINFO_EXTENSION));
345
346
                // set the default minifiers based on the extension
347
                switch ($extension) {
348
                    case 'png':
349
                        $minifier = 'optipng';
350
                        break;
351
                    case 'jpg':
352
                    case 'jpeg':
353
                        $minifier = 'jpegtran';
354
                        break;
355
                    case 'gif':
356
                        $minifier = 'gifsicle';
357
                        break;
358
                    case 'svg':
359
                        $minifier = 'svgo';
360
                        break;
361
                }
362
            } else {
363 View Code Duplication
                if (!in_array($this->minifier, $this->minifiers, true)
364
                    && !is_callable(strtr($this->minifier, '-', '_'))
365
                ) {
366
                    $message = sprintf('Invalid minifier %s!', $this->minifier);
367
368
                    return Result::error($this, $message);
369
                }
370
                $minifier = $this->minifier;
371
            }
372
373
            // Convert minifier name to camelCase (e.g. jpeg-recompress)
374
            $funcMinifier = $this->camelCase($minifier);
0 ignored issues
show
The variable $minifier does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
375
376
            // call the minifier method which prepares the command
377
            if (is_callable($funcMinifier)) {
378
                $command = call_user_func($funcMinifier, $from, $to, $this->minifierOptions);
379
            } elseif (method_exists($this, $funcMinifier)) {
380
                $command = $this->{$funcMinifier}($from, $to);
381
            } else {
382
                $message = sprintf('Minifier method <info>%s</info> cannot be found!', $funcMinifier);
383
384
                return Result::error($this, $message);
385
            }
386
387
            // launch the command
388
            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
389
            $result = $this->executeCommand($command);
390
391
            // check the return code
392
            if ($result->getExitCode() == 127) {
393
                $this->printTaskError('The {minifier} executable cannot be found', ['minifier' => $minifier]);
394
                // try to install from imagemin repository
395
                if (array_key_exists($minifier, $this->imageminRepos)) {
396
                    $result = $this->installFromImagemin($minifier);
397
                    if ($result instanceof Result) {
398
                        if ($result->wasSuccessful()) {
399
                            $this->printTaskSuccess($result->getMessage());
400
                            // retry the conversion with the downloaded executable
401
                            if (is_callable($minifier)) {
402
                                $command = call_user_func($minifier, $from, $to, $minifierOptions);
0 ignored issues
show
The variable $minifierOptions does not exist. Did you forget to declare it?

This check marks access to variables or properties that have not been declared yet. While PHP has no explicit notion of declaring a variable, accessing it before a value is assigned to it is most likely a bug.

Loading history...
403
                            } elseif (method_exists($this, $minifier)) {
404
                                $command = $this->{$minifier}($from, $to);
405
                            }
406
                            // launch the command
407
                            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
408
                            $result = $this->executeCommand($command);
409
                        } else {
410
                            $this->printTaskError($result->getMessage());
411
                            // the download was not successful
412
                            return $result;
413
                        }
414
                    }
415
                } else {
416
                    return $result;
417
                }
418
            }
419
420
            // check the success of the conversion
421
            if ($result->getExitCode() !== 0) {
422
                $this->results['error'][] = $from;
423
            } else {
424
                $this->results['success'][] = $from;
425
            }
426
        }
427
    }
428
429
    /**
430
     * @return string
431
     */
432 View Code Duplication
    protected function getOS()
433
    {
434
        $os = php_uname('s');
435
        $os .= '/'.php_uname('m');
436
        // replace x86_64 to x64, because the imagemin repo uses that
437
        $os = str_replace('x86_64', 'x64', $os);
438
        // replace i386, i686, etc to x86, because of imagemin
439
        $os = preg_replace('/i[0-9]86/', 'x86', $os);
440
        // turn info to lowercase, because of imagemin
441
        $os = strtolower($os);
442
443
        return $os;
444
    }
445
446
    /**
447
     * @param string $command
448
     *
449
     * @return \Robo\Result
450
     */
451
    protected function executeCommand($command)
452
    {
453
        // insert the options into the command
454
        $a = explode(' ', $command);
455
        $executable = array_shift($a);
456
        foreach ($this->minifierOptions as $key => $value) {
457
            // first prepend the value
458
            if (!empty($value)) {
459
                array_unshift($a, $value);
460
            }
461
            // then add the key
462
            if (!is_numeric($key)) {
463
                array_unshift($a, $key);
464
            }
465
        }
466
        // check if the executable can be replaced with the downloaded one
467
        if (array_key_exists($executable, $this->executablePaths)) {
468
            $executable = $this->executablePaths[$executable];
469
        }
470
        array_unshift($a, $executable);
471
        $command = implode(' ', $a);
472
473
        // execute the command
474
        $exec = new Exec($command);
475
476
        return $exec->inflect($this)->printed(false)->run();
477
    }
478
479
    /**
480
     * @param string $executable
481
     *
482
     * @return \Robo\Result
483
     */
484
    protected function installFromImagemin($executable)
485
    {
486
        // check if there is an url defined for the executable
487
        if (!array_key_exists($executable, $this->imageminRepos)) {
488
            $message = sprintf('The executable %s cannot be found in the defined imagemin repositories', $executable);
489
490
            return Result::error($this, $message);
491
        }
492
        $this->printTaskInfo('Downloading the {executable} executable from the imagemin repository', ['executable' => $executable]);
493
494
        $os = $this->getOS();
495
        $url = $this->imageminRepos[$executable].'/blob/master/vendor/'.$os.'/'.$executable.'?raw=true';
496
        if (substr($os, 0, 3) == 'win') {
497
            // if it is win, add a .exe extension
498
            $url = $this->imageminRepos[$executable].'/blob/master/vendor/'.$os.'/'.$executable.'.exe?raw=true';
499
        }
500
        $data = @file_get_contents($url, false, null);
501
        if ($data === false) {
502
            // there is something wrong with the url, try it without the version info
503
            $url = preg_replace('/x[68][64]\//', '', $url);
504
            $data = @file_get_contents($url, false, null);
505
            if ($data === false) {
506
                // there is still something wrong with the url if it is win, try with win32
507
                if (substr($os, 0, 3) == 'win') {
508
                    $url = preg_replace('win/', 'win32/', $url);
509
                    $data = @file_get_contents($url, false, null);
510
                    if ($data === false) {
511
                        // there is nothing more we can do
512
                        $message = sprintf('Could not download the executable <info>%s</info>', $executable);
513
514
                        return Result::error($this, $message);
515
                    }
516
                }
517
                // if it is not windows there is nothing we can do
518
                $message = sprintf('Could not download the executable <info>%s</info>', $executable);
519
520
                return Result::error($this, $message);
521
            }
522
        }
523
        // check if target directory exists
524
        if (!is_dir($this->executableTargetDir)) {
525
            mkdir($this->executableTargetDir);
526
        }
527
        // save the executable into the target dir
528
        $path = $this->executableTargetDir.'/'.$executable;
529
        if (substr($os, 0, 3) == 'win') {
530
            // if it is win, add a .exe extension
531
            $path = $this->executableTargetDir.'/'.$executable.'.exe';
532
        }
533
        $result = file_put_contents($path, $data);
534
        if ($result === false) {
535
            $message = sprintf('Could not copy the executable <info>%s</info> to %s', $executable, $target_dir);
536
537
            return Result::error($this, $message);
538
        }
539
        // set the binary to executable
540
        chmod($path, 0755);
541
542
        // if everything successful, store the executable path
543
        $this->executablePaths[$executable] = $this->executableTargetDir.'/'.$executable;
544
        // if it is win, add a .exe extension
545
        if (substr($os, 0, 3) == 'win') {
546
            $this->executablePaths[$executable] .= '.exe';
547
        }
548
549
        $message = sprintf('Executable <info>%s</info> successfully downloaded', $executable);
550
551
        return Result::success($this, $message);
552
    }
553
554
    /**
555
     * @param string $from
556
     * @param string $to
557
     *
558
     * @return string
559
     */
560
    protected function optipng($from, $to)
561
    {
562
        $command = sprintf('optipng -quiet -out "%s" -- "%s"', $to, $from);
563
        if ($from != $to && is_file($to)) {
564
            // earlier versions of optipng do not overwrite the target without a backup
565
            // http://sourceforge.net/p/optipng/bugs/37/
566
            unlink($to);
567
        }
568
569
        return $command;
570
    }
571
572
    /**
573
     * @param string $from
574
     * @param string $to
575
     *
576
     * @return string
577
     */
578
    protected function jpegtran($from, $to)
579
    {
580
        $command = sprintf('jpegtran -optimize -outfile "%s" "%s"', $to, $from);
581
582
        return $command;
583
    }
584
585
    protected function gifsicle($from, $to)
586
    {
587
        $command = sprintf('gifsicle -o "%s" "%s"', $to, $from);
588
589
        return $command;
590
    }
591
592
    /**
593
     * @param string $from
594
     * @param string $to
595
     *
596
     * @return string
597
     */
598
    protected function svgo($from, $to)
599
    {
600
        $command = sprintf('svgo "%s" "%s"', $from, $to);
601
602
        return $command;
603
    }
604
605
    /**
606
     * @param string $from
607
     * @param string $to
608
     *
609
     * @return string
610
     */
611
    protected function pngquant($from, $to)
612
    {
613
        $command = sprintf('pngquant --force --output "%s" "%s"', $to, $from);
614
615
        return $command;
616
    }
617
618
    /**
619
     * @param string $from
620
     * @param string $to
621
     *
622
     * @return string
623
     */
624
    protected function advpng($from, $to)
625
    {
626
        // advpng does not have any output parameters, copy the file and then compress the copy
627
        $command = sprintf('advpng --recompress --quiet "%s"', $to);
628
        $this->fs->copy($from, $to, true);
629
630
        return $command;
631
    }
632
633
    /**
634
     * @param string $from
635
     * @param string $to
636
     *
637
     * @return string
638
     */
639
    protected function pngout($from, $to)
640
    {
641
        $command = sprintf('pngout -y -q "%s" "%s"', $from, $to);
642
643
        return $command;
644
    }
645
646
    /**
647
     * @param string $from
648
     * @param string $to
649
     *
650
     * @return string
651
     */
652
    protected function zopflipng($from, $to)
653
    {
654
        $command = sprintf('zopflipng -y "%s" "%s"', $from, $to);
655
656
        return $command;
657
    }
658
659
    /**
660
     * @param string $from
661
     * @param string $to
662
     *
663
     * @return string
664
     */
665
    protected function pngcrush($from, $to)
666
    {
667
        $command = sprintf('pngcrush -q -ow "%s" "%s"', $from, $to);
668
669
        return $command;
670
    }
671
672
    /**
673
     * @param string $from
674
     * @param string $to
675
     *
676
     * @return string
677
     */
678
    protected function jpegoptim($from, $to)
679
    {
680
        // jpegoptim only takes the destination directory as an argument
681
        $command = sprintf('jpegoptim --quiet -o --dest "%s" "%s"', dirname($to), $from);
682
683
        return $command;
684
    }
685
686
    /**
687
     * @param string $from
688
     * @param string $to
689
     *
690
     * @return string
691
     */
692
    protected function jpegRecompress($from, $to)
693
    {
694
        $command = sprintf('jpeg-recompress --quiet "%s" "%s"', $from, $to);
695
696
        return $command;
697
    }
698
699
    /**
700
     * @param string $text
701
     *
702
     * @return string
703
     */
704
    public static function camelCase($text)
705
    {
706
        // non-alpha and non-numeric characters become spaces
707
        $text = preg_replace('/[^a-z0-9]+/i', ' ', $text);
708
        $text = trim($text);
709
        // uppercase the first character of each word
710
        $text = ucwords($text);
711
        $text = str_replace(" ", "", $text);
712
        $text = lcfirst($text);
713
714
        return $text;
715
    }
716
}
717