Completed
Push — master ( 8b8afe...5f2bbe )
by Greg
02:21
created

src/Task/Assets/ImageMinify.php (1 issue)

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);
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);
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();
0 ignored issues
show
Deprecated Code introduced by
The method Robo\Common\ExecTrait::printed() has been deprecated.

This method has been deprecated.

Loading history...
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