1aa625932fcdbb53280af20923f9e1ccac9f3df4
[yaffs-website] / vendor / consolidation / robo / src / Task / Assets / ImageMinify.php
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     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                 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     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 }