ImageMinify::executeCommand()   A
last analyzed

Complexity

Conditions 5
Paths 10

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 27
rs 9.1768
c 0
b 0
f 0
cc 5
nc 10
nop 1
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 string[]
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
    /**
175
     * @param string|string[] $dirs
176
     */
177
    public function __construct($dirs)
178
    {
179
        is_array($dirs)
180
            ? $this->dirs = $dirs
181
            : $this->dirs[] = $dirs;
182
183
        $this->fs = new sfFilesystem();
184
185
        // guess the best path for the executables based on __DIR__
186
        if (($pos = strpos(__DIR__, 'consolidation/robo')) !== false) {
187
            // the executables should be stored in vendor/bin
188
            $this->executableTargetDir = substr(__DIR__, 0, $pos) . 'bin';
189
        }
190
191
        // check if the executables are already available
192
        foreach ($this->imageminRepos as $exec => $url) {
193
            $path = $this->executableTargetDir . '/' . $exec;
194
            // if this is Windows add a .exe extension
195
            if (substr($this->getOS(), 0, 3) == 'win') {
196
                $path .= '.exe';
197
            }
198
            if (is_file($path)) {
199
                $this->executablePaths[$exec] = $path;
200
            }
201
        }
202
    }
203
204
    /**
205
     * {@inheritdoc}
206
     */
207
    public function run()
208
    {
209
        // find the files
210
        $files = $this->findFiles($this->dirs);
211
212
        // minify the files
213
        $result = $this->minify($files);
0 ignored issues
show
Documentation introduced by
$files is of type object<Robo\Result>|array, but the function expects a array<integer,string>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
214
        // check if there was an error
215
        if ($result instanceof Result) {
216
            return $result;
217
        }
218
219
        $amount = (count($files) == 1 ? 'image' : 'images');
220
        $message = "Minified {filecount} out of {filetotal} $amount into {destination}";
221
        $context = ['filecount' => count($this->results['success']), 'filetotal' => count($files), 'destination' => $this->to];
222
223
        if (count($this->results['success']) == count($files)) {
224
            $this->printTaskSuccess($message, $context);
225
226
            return Result::success($this, $message, $context);
227
        } else {
228
            return Result::error($this, $message, $context);
229
        }
230
    }
231
232
    /**
233
     * Sets the target directory where the files will be copied to.
234
     *
235
     * @param string $target
236
     *
237
     * @return $this
238
     */
239
    public function to($target)
240
    {
241
        $this->to = rtrim($target, '/');
242
243
        return $this;
244
    }
245
246
    /**
247
     * Sets the minifier.
248
     *
249
     * @param string $minifier
250
     * @param array  $options
251
     *
252
     * @return $this
253
     */
254
    public function minifier($minifier, array $options = [])
255
    {
256
        $this->minifier = $minifier;
257
        $this->minifierOptions = array_merge($this->minifierOptions, $options);
258
259
        return $this;
260
    }
261
262
    /**
263
     * @param string[] $dirs
264
     *
265
     * @return array|\Robo\Result
266
     *
267
     * @throws \Robo\Exception\TaskException
268
     */
269 View Code Duplication
    protected function findFiles($dirs)
0 ignored issues
show
Duplication introduced by
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...
270
    {
271
        $files = array();
272
273
        // find the files
274
        foreach ($dirs as $k => $v) {
275
            // reset finder
276
            $finder = new Finder();
277
278
            $dir = $k;
279
            $to = $v;
280
            // check if target was given with the to() method instead of key/value pairs
281
            if (is_int($k)) {
282
                $dir = $v;
283
                if (isset($this->to)) {
284
                    $to = $this->to;
285
                } else {
286
                    throw new TaskException($this, 'target directory is not defined');
287
                }
288
            }
289
290
            try {
291
                $finder->files()->in($dir);
292
            } catch (\InvalidArgumentException $e) {
293
                // if finder cannot handle it, try with in()->name()
294
                if (strpos($dir, '/') === false) {
295
                    $dir = './' . $dir;
296
                }
297
                $parts = explode('/', $dir);
298
                $new_dir = implode('/', array_slice($parts, 0, -1));
299
                try {
300
                    $finder->files()->in($new_dir)->name(array_pop($parts));
301
                } catch (\InvalidArgumentException $e) {
302
                    return Result::fromException($this, $e);
303
                }
304
            }
305
306
            foreach ($finder as $file) {
307
                // store the absolute path as key and target as value in the files array
308
                $files[$file->getRealpath()] = $this->getTarget($file->getRealPath(), $to);
309
            }
310
            $fileNoun = count($finder) == 1 ? ' file' : ' files';
311
            $this->printTaskInfo("Found {filecount} $fileNoun in {dir}", ['filecount' => count($finder), 'dir' => $dir]);
312
        }
313
314
        return $files;
315
    }
316
317
    /**
318
     * @param string $file
319
     * @param string $to
320
     *
321
     * @return string
322
     */
323
    protected function getTarget($file, $to)
324
    {
325
        $target = $to . '/' . basename($file);
326
327
        return $target;
328
    }
329
330
    /**
331
     * @param string[] $files
332
     *
333
     * @return \Robo\Result
334
     */
335
    protected function minify($files)
336
    {
337
        // store the individual results into the results array
338
        $this->results = [
339
            'success' => [],
340
            'error' => [],
341
        ];
342
343
        // loop through the files
344
        foreach ($files as $from => $to) {
345
            $minifier = '';
346
347
            if (!isset($this->minifier)) {
348
                // check filetype based on the extension
349
                $extension = strtolower(pathinfo($from, PATHINFO_EXTENSION));
350
351
                // set the default minifiers based on the extension
352
                switch ($extension) {
353
                    case 'png':
354
                        $minifier = 'optipng';
355
                        break;
356
                    case 'jpg':
357
                    case 'jpeg':
358
                        $minifier = 'jpegtran';
359
                        break;
360
                    case 'gif':
361
                        $minifier = 'gifsicle';
362
                        break;
363
                    case 'svg':
364
                        $minifier = 'svgo';
365
                        break;
366
                }
367
            } else {
368 View Code Duplication
                if (!in_array($this->minifier, $this->minifiers, true)
0 ignored issues
show
Duplication introduced by
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...
369
                    && !is_callable(strtr($this->minifier, '-', '_'))
370
                ) {
371
                    $message = sprintf('Invalid minifier %s!', $this->minifier);
372
373
                    return Result::error($this, $message);
374
                }
375
                $minifier = $this->minifier;
376
            }
377
378
            // Convert minifier name to camelCase (e.g. jpeg-recompress)
379
            $funcMinifier = $this->camelCase($minifier);
380
381
            // call the minifier method which prepares the command
382
            if (is_callable($funcMinifier)) {
383
                $command = call_user_func($funcMinifier, $from, $to, $this->minifierOptions);
384
            } elseif (method_exists($this, $funcMinifier)) {
385
                $command = $this->{$funcMinifier}($from, $to);
386
            } else {
387
                $message = sprintf('Minifier method <info>%s</info> cannot be found!', $funcMinifier);
388
389
                return Result::error($this, $message);
390
            }
391
392
            // launch the command
393
            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
394
            $result = $this->executeCommand($command);
395
396
            // check the return code
397
            if ($result->getExitCode() == 127) {
398
                $this->printTaskError('The {minifier} executable cannot be found', ['minifier' => $minifier]);
399
                // try to install from imagemin repository
400
                if (array_key_exists($minifier, $this->imageminRepos)) {
401
                    $result = $this->installFromImagemin($minifier);
402
                    if ($result instanceof Result) {
403
                        if ($result->wasSuccessful()) {
404
                            $this->printTaskSuccess($result->getMessage());
405
                            // retry the conversion with the downloaded executable
406
                            if (is_callable($minifier)) {
407
                                $command = call_user_func($minifier, $from, $to, $this->minifierOptions);
408
                            } elseif (method_exists($this, $minifier)) {
409
                                $command = $this->{$minifier}($from, $to);
410
                            }
411
                            // launch the command
412
                            $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
413
                            $result = $this->executeCommand($command);
414
                        } else {
415
                            $this->printTaskError($result->getMessage());
416
                            // the download was not successful
417
                            return $result;
418
                        }
419
                    }
420
                } else {
421
                    return $result;
422
                }
423
            }
424
425
            // check the success of the conversion
426
            if ($result->getExitCode() !== 0) {
427
                $this->results['error'][] = $from;
428
            } else {
429
                $this->results['success'][] = $from;
430
            }
431
        }
432
    }
433
434
    /**
435
     * @return string
436
     */
437 View Code Duplication
    protected function getOS()
0 ignored issues
show
Duplication introduced by
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...
438
    {
439
        $os = php_uname('s');
440
        $os .= '/' . php_uname('m');
441
        // replace x86_64 to x64, because the imagemin repo uses that
442
        $os = str_replace('x86_64', 'x64', $os);
443
        // replace i386, i686, etc to x86, because of imagemin
444
        $os = preg_replace('/i[0-9]86/', 'x86', $os);
445
        // turn info to lowercase, because of imagemin
446
        $os = strtolower($os);
447
448
        return $os;
449
    }
450
451
    /**
452
     * @param string $command
453
     *
454
     * @return \Robo\Result
455
     */
456
    protected function executeCommand($command)
457
    {
458
        // insert the options into the command
459
        $a = explode(' ', $command);
460
        $executable = array_shift($a);
461
        foreach ($this->minifierOptions as $key => $value) {
462
            // first prepend the value
463
            if (!empty($value)) {
464
                array_unshift($a, $value);
465
            }
466
            // then add the key
467
            if (!is_numeric($key)) {
468
                array_unshift($a, $key);
469
            }
470
        }
471
        // check if the executable can be replaced with the downloaded one
472
        if (array_key_exists($executable, $this->executablePaths)) {
473
            $executable = $this->executablePaths[$executable];
474
        }
475
        array_unshift($a, $executable);
476
        $command = implode(' ', $a);
477
478
        // execute the command
479
        $exec = new Exec($command);
480
481
        return $exec->inflect($this)->printed(false)->run();
0 ignored issues
show
Deprecated Code introduced by
The method Robo\Common\ExecTrait::printed() has been deprecated.

This method has been deprecated.

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