ImageMinify::run()   A
last analyzed

Complexity

Conditions 4
Paths 5

Size

Total Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 24
rs 9.536
c 0
b 0
f 0
cc 4
nc 5
nop 0
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);
0 ignored issues
show
Bug introduced by Gábor Udvari
It seems like $files defined by $this->findFiles($this->dirs) on line 207 can also be of type object<Robo\Result>; however, Robo\Task\Assets\ImageMinify::minify() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
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)
0 ignored issues
show
Duplication introduced by Gábor Udvari
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
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
            $minifier = '';
343
344
            if (!isset($this->minifier)) {
345
                // check filetype based on the extension
346
                $extension = strtolower(pathinfo($from, PATHINFO_EXTENSION));
347
348
                // set the default minifiers based on the extension
349
                switch ($extension) {
350
                    case 'png':
351
                        $minifier = 'optipng';
352
                        break;
353
                    case 'jpg':
354
                    case 'jpeg':
355
                        $minifier = 'jpegtran';
356
                        break;
357
                    case 'gif':
358
                        $minifier = 'gifsicle';
359
                        break;
360
                    case 'svg':
361
                        $minifier = 'svgo';
362
                        break;
363
                }
364
            } else {
365 View Code Duplication
                if (!in_array($this->minifier, $this->minifiers, true)
0 ignored issues
show
Duplication introduced by Gábor Udvari
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
366
                    && !is_callable(strtr($this->minifier, '-', '_'))
367
                ) {
368
                    $message = sprintf('Invalid minifier %s!', $this->minifier);
369
370
                    return Result::error($this, $message);
371
                }
372
                $minifier = $this->minifier;
373
            }
374
375
            // Convert minifier name to camelCase (e.g. jpeg-recompress)
376
            $funcMinifier = $this->camelCase($minifier);
377
378
            // call the minifier method which prepares the command
379
            if (is_callable($funcMinifier)) {
380
                $command = call_user_func($funcMinifier, $from, $to, $this->minifierOptions);
381
            } elseif (method_exists($this, $funcMinifier)) {
382
                $command = $this->{$funcMinifier}($from, $to);
383
            } else {
384
                $message = sprintf('Minifier method <info>%s</info> cannot be found!', $funcMinifier);
385
386
                return Result::error($this, $message);
387
            }
388
389
            // launch the command
390
            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
391
            $result = $this->executeCommand($command);
392
393
            // check the return code
394
            if ($result->getExitCode() == 127) {
395
                $this->printTaskError('The {minifier} executable cannot be found', ['minifier' => $minifier]);
396
                // try to install from imagemin repository
397
                if (array_key_exists($minifier, $this->imageminRepos)) {
398
                    $result = $this->installFromImagemin($minifier);
399
                    if ($result instanceof Result) {
400
                        if ($result->wasSuccessful()) {
401
                            $this->printTaskSuccess($result->getMessage());
402
                            // retry the conversion with the downloaded executable
403
                            if (is_callable($minifier)) {
404
                                $command = call_user_func($minifier, $from, $to, $minifierOptions);
0 ignored issues
show
Bug introduced by Gábor Udvari
The variable $minifierOptions does not exist. Did you mean $minifier?

This check looks for variables that are accessed but have not been defined. It raises an issue if it finds another variable that has a similar name.

The variable may have been renamed without also renaming all references.

Loading history...
405
                            } elseif (method_exists($this, $minifier)) {
406
                                $command = $this->{$minifier}($from, $to);
407
                            }
408
                            // launch the command
409
                            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
410
                            $result = $this->executeCommand($command);
411
                        } else {
412
                            $this->printTaskError($result->getMessage());
413
                            // the download was not successful
414
                            return $result;
415
                        }
416
                    }
417
                } else {
418
                    return $result;
419
                }
420
            }
421
422
            // check the success of the conversion
423
            if ($result->getExitCode() !== 0) {
424
                $this->results['error'][] = $from;
425
            } else {
426
                $this->results['success'][] = $from;
427
            }
428
        }
429
    }
430
431
    /**
432
     * @return string
433
     */
434 View Code Duplication
    protected function getOS()
0 ignored issues
show
Duplication introduced by Gábor Udvari
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
435
    {
436
        $os = php_uname('s');
437
        $os .= '/'.php_uname('m');
438
        // replace x86_64 to x64, because the imagemin repo uses that
439
        $os = str_replace('x86_64', 'x64', $os);
440
        // replace i386, i686, etc to x86, because of imagemin
441
        $os = preg_replace('/i[0-9]86/', 'x86', $os);
442
        // turn info to lowercase, because of imagemin
443
        $os = strtolower($os);
444
445
        return $os;
446
    }
447
448
    /**
449
     * @param string $command
450
     *
451
     * @return \Robo\Result
452
     */
453
    protected function executeCommand($command)
454
    {
455
        // insert the options into the command
456
        $a = explode(' ', $command);
457
        $executable = array_shift($a);
458
        foreach ($this->minifierOptions as $key => $value) {
459
            // first prepend the value
460
            if (!empty($value)) {
461
                array_unshift($a, $value);
462
            }
463
            // then add the key
464
            if (!is_numeric($key)) {
465
                array_unshift($a, $key);
466
            }
467
        }
468
        // check if the executable can be replaced with the downloaded one
469
        if (array_key_exists($executable, $this->executablePaths)) {
470
            $executable = $this->executablePaths[$executable];
471
        }
472
        array_unshift($a, $executable);
473
        $command = implode(' ', $a);
474
475
        // execute the command
476
        $exec = new Exec($command);
477
478
        return $exec->inflect($this)->printed(false)->run();
0 ignored issues
show
Deprecated Code introduced by Greg Anderson
The method Robo\Common\ExecTrait::printed() has been deprecated.

This method has been deprecated.

Loading history...
479
    }
480
481
    /**
482
     * @param string $executable
483
     *
484
     * @return \Robo\Result
485
     */
486
    protected function installFromImagemin($executable)
487
    {
488
        // check if there is an url defined for the executable
489
        if (!array_key_exists($executable, $this->imageminRepos)) {
490
            $message = sprintf('The executable %s cannot be found in the defined imagemin repositories', $executable);
491
492
            return Result::error($this, $message);
493
        }
494
        $this->printTaskInfo('Downloading the {executable} executable from the imagemin repository', ['executable' => $executable]);
495
496
        $os = $this->getOS();
497
        $url = $this->imageminRepos[$executable].'/blob/master/vendor/'.$os.'/'.$executable.'?raw=true';
498
        if (substr($os, 0, 3) == 'win') {
499
            // if it is win, add a .exe extension
500
            $url = $this->imageminRepos[$executable].'/blob/master/vendor/'.$os.'/'.$executable.'.exe?raw=true';
501
        }
502
        $data = @file_get_contents($url, false, null);
503
        if ($data === false) {
504
            // there is something wrong with the url, try it without the version info
505
            $url = preg_replace('/x[68][64]\//', '', $url);
506
            $data = @file_get_contents($url, false, null);
507
            if ($data === false) {
508
                // there is still something wrong with the url if it is win, try with win32
509
                if (substr($os, 0, 3) == 'win') {
510
                    $url = preg_replace('win/', 'win32/', $url);
511
                    $data = @file_get_contents($url, false, null);
512
                    if ($data === false) {
513
                        // there is nothing more we can do
514
                        $message = sprintf('Could not download the executable <info>%s</info>', $executable);
515
516
                        return Result::error($this, $message);
517
                    }
518
                }
519
                // if it is not windows there is nothing we can do
520
                $message = sprintf('Could not download the executable <info>%s</info>', $executable);
521
522
                return Result::error($this, $message);
523
            }
524
        }
525
        // check if target directory exists
526
        if (!is_dir($this->executableTargetDir)) {
527
            mkdir($this->executableTargetDir);
528
        }
529
        // save the executable into the target dir
530
        $path = $this->executableTargetDir.'/'.$executable;
531
        if (substr($os, 0, 3) == 'win') {
532
            // if it is win, add a .exe extension
533
            $path = $this->executableTargetDir.'/'.$executable.'.exe';
534
        }
535
        $result = file_put_contents($path, $data);
536
        if ($result === false) {
537
            $message = sprintf('Could not copy the executable <info>%s</info> to %s', $executable, $target_dir);
0 ignored issues
show
Bug introduced by Gábor Udvari
The variable $target_dir 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...
538
539
            return Result::error($this, $message);
540
        }
541
        // set the binary to executable
542
        chmod($path, 0755);
543
544
        // if everything successful, store the executable path
545
        $this->executablePaths[$executable] = $this->executableTargetDir.'/'.$executable;
546
        // if it is win, add a .exe extension
547
        if (substr($os, 0, 3) == 'win') {
548
            $this->executablePaths[$executable] .= '.exe';
549
        }
550
551
        $message = sprintf('Executable <info>%s</info> successfully downloaded', $executable);
552
553
        return Result::success($this, $message);
554
    }
555
556
    /**
557
     * @param string $from
558
     * @param string $to
559
     *
560
     * @return string
561
     */
562
    protected function optipng($from, $to)
563
    {
564
        $command = sprintf('optipng -quiet -out "%s" -- "%s"', $to, $from);
565
        if ($from != $to && is_file($to)) {
566
            // earlier versions of optipng do not overwrite the target without a backup
567
            // http://sourceforge.net/p/optipng/bugs/37/
568
            unlink($to);
569
        }
570
571
        return $command;
572
    }
573
574
    /**
575
     * @param string $from
576
     * @param string $to
577
     *
578
     * @return string
579
     */
580
    protected function jpegtran($from, $to)
581
    {
582
        $command = sprintf('jpegtran -optimize -outfile "%s" "%s"', $to, $from);
583
584
        return $command;
585
    }
586
587
    protected function gifsicle($from, $to)
588
    {
589
        $command = sprintf('gifsicle -o "%s" "%s"', $to, $from);
590
591
        return $command;
592
    }
593
594
    /**
595
     * @param string $from
596
     * @param string $to
597
     *
598
     * @return string
599
     */
600
    protected function svgo($from, $to)
601
    {
602
        $command = sprintf('svgo "%s" "%s"', $from, $to);
603
604
        return $command;
605
    }
606
607
    /**
608
     * @param string $from
609
     * @param string $to
610
     *
611
     * @return string
612
     */
613
    protected function pngquant($from, $to)
614
    {
615
        $command = sprintf('pngquant --force --output "%s" "%s"', $to, $from);
616
617
        return $command;
618
    }
619
620
    /**
621
     * @param string $from
622
     * @param string $to
623
     *
624
     * @return string
625
     */
626
    protected function advpng($from, $to)
627
    {
628
        // advpng does not have any output parameters, copy the file and then compress the copy
629
        $command = sprintf('advpng --recompress --quiet "%s"', $to);
630
        $this->fs->copy($from, $to, true);
631
632
        return $command;
633
    }
634
635
    /**
636
     * @param string $from
637
     * @param string $to
638
     *
639
     * @return string
640
     */
641
    protected function pngout($from, $to)
642
    {
643
        $command = sprintf('pngout -y -q "%s" "%s"', $from, $to);
644
645
        return $command;
646
    }
647
648
    /**
649
     * @param string $from
650
     * @param string $to
651
     *
652
     * @return string
653
     */
654
    protected function zopflipng($from, $to)
655
    {
656
        $command = sprintf('zopflipng -y "%s" "%s"', $from, $to);
657
658
        return $command;
659
    }
660
661
    /**
662
     * @param string $from
663
     * @param string $to
664
     *
665
     * @return string
666
     */
667
    protected function pngcrush($from, $to)
668
    {
669
        $command = sprintf('pngcrush -q -ow "%s" "%s"', $from, $to);
670
671
        return $command;
672
    }
673
674
    /**
675
     * @param string $from
676
     * @param string $to
677
     *
678
     * @return string
679
     */
680
    protected function jpegoptim($from, $to)
681
    {
682
        // jpegoptim only takes the destination directory as an argument
683
        $command = sprintf('jpegoptim --quiet -o --dest "%s" "%s"', dirname($to), $from);
684
685
        return $command;
686
    }
687
688
    /**
689
     * @param string $from
690
     * @param string $to
691
     *
692
     * @return string
693
     */
694
    protected function jpegRecompress($from, $to)
695
    {
696
        $command = sprintf('jpeg-recompress --quiet "%s" "%s"', $from, $to);
697
698
        return $command;
699
    }
700
701
    /**
702
     * @param string $text
703
     *
704
     * @return string
705
     */
706
    public static function camelCase($text)
707
    {
708
        // non-alpha and non-numeric characters become spaces
709
        $text = preg_replace('/[^a-z0-9]+/i', ' ', $text);
710
        $text = trim($text);
711
        // uppercase the first character of each word
712
        $text = ucwords($text);
713
        $text = str_replace(" ", "", $text);
714
        $text = lcfirst($text);
715
716
        return $text;
717
    }
718
}
719