3 namespace Robo\Task\Assets;
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;
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.
16 * When the task is run without any specified minifier it will compress the images
17 * based on the extension.
20 * $this->taskImageMinify('assets/images/*')
21 * ->to('dist/images/')
25 * This will use the following minifiers:
29 * - JPG, JPEG: jpegtran
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:
36 * $this->taskImageMinify('assets/images/*.png')
37 * ->to('dist/images/')
38 * ->minifier('pngcrush');
42 * The task supports the following minifiers:
54 * - svgo (only minification, no downloading)
56 * You can also specifiy extra options for the minifiers:
59 * $this->taskImageMinify('assets/images/*.jpg')
60 * ->to('dist/images/')
61 * ->minifier('jpegtran', ['-progressive' => null, '-copy' => 'none'])
65 * This will execute as:
66 * `jpegtran -copy none -progressive -optimize -outfile "dist/images/test.jpg" "/var/www/test/assets/images/test.jpg"`
68 class ImageMinify extends BaseTask
71 * Destination directory for the minified images.
78 * Array of the source files.
85 * Symfony 2 filesystem.
92 * Target directory for the downloaded binary executables.
96 protected $executableTargetDir;
99 * Array for the downloaded binary executables.
103 protected $executablePaths = [];
106 * Array for the individual results of all the files.
110 protected $results = [];
113 * Default minifier to use.
120 * Array for minifier options.
124 protected $minifierOptions = [];
127 * Supported minifiers.
131 protected $minifiers = [
149 * Binary repositories of Imagemin.
151 * @link https://github.com/imagemin
155 protected $imageminRepos = [
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',
164 'gifsicle' => 'https://github.com/imagemin/gifsicle-bin',
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',
171 'cwebp' => 'https://github.com/imagemin/cwebp-bin', // note: we do not support this minifier because it creates WebP from non-WebP files
174 public function __construct($dirs)
177 ? $this->dirs = $dirs
178 : $this->dirs[] = $dirs;
180 $this->fs = new sfFilesystem();
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';
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') {
195 if (is_file($path)) {
196 $this->executablePaths[$exec] = $path;
204 public function run()
207 $files = $this->findFiles($this->dirs);
210 $result = $this->minify($files);
211 // check if there was an error
212 if ($result instanceof Result) {
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];
220 if (count($this->results['success']) == count($files)) {
221 $this->printTaskSuccess($message, $context);
223 return Result::success($this, $message, $context);
225 return Result::error($this, $message, $context);
230 * Sets the target directory where the files will be copied to.
232 * @param string $target
236 public function to($target)
238 $this->to = rtrim($target, '/');
246 * @param string $minifier
247 * @param array $options
251 public function minifier($minifier, array $options = [])
253 $this->minifier = $minifier;
254 $this->minifierOptions = array_merge($this->minifierOptions, $options);
262 * @return array|\Robo\Result
264 * @throws \Robo\Exception\TaskException
266 protected function findFiles($dirs)
271 foreach ($dirs as $k => $v) {
273 $finder = new Finder();
277 // check if target was given with the to() method instead of key/value pairs
280 if (isset($this->to)) {
283 throw new TaskException($this, 'target directory is not defined');
288 $finder->files()->in($dir);
289 } catch (\InvalidArgumentException $e) {
290 // if finder cannot handle it, try with in()->name()
291 if (strpos($dir, '/') === false) {
294 $parts = explode('/', $dir);
295 $new_dir = implode('/', array_slice($parts, 0, -1));
297 $finder->files()->in($new_dir)->name(array_pop($parts));
298 } catch (\InvalidArgumentException $e) {
299 return Result::fromException($this, $e);
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);
307 $fileNoun = count($finder) == 1 ? ' file' : ' files';
308 $this->printTaskInfo("Found {filecount} $fileNoun in {dir}", ['filecount' => count($finder), 'dir' => $dir]);
315 * @param string $file
320 protected function getTarget($file, $to)
322 $target = $to.'/'.basename($file);
328 * @param array $files
330 * @return \Robo\Result
332 protected function minify($files)
334 // store the individual results into the results array
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));
346 // set the default minifiers based on the extension
347 switch ($extension) {
349 $minifier = 'optipng';
353 $minifier = 'jpegtran';
356 $minifier = 'gifsicle';
363 if (!in_array($this->minifier, $this->minifiers, true)
364 && !is_callable(strtr($this->minifier, '-', '_'))
366 $message = sprintf('Invalid minifier %s!', $this->minifier);
368 return Result::error($this, $message);
370 $minifier = $this->minifier;
373 // Convert minifier name to camelCase (e.g. jpeg-recompress)
374 $funcMinifier = $this->camelCase($minifier);
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);
382 $message = sprintf('Minifier method <info>%s</info> cannot be found!', $funcMinifier);
384 return Result::error($this, $message);
387 // launch the command
388 $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
389 $result = $this->executeCommand($command);
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);
406 // launch the command
407 $this->printTaskInfo('Minifying {filepath} with {minifier}', ['filepath' => $from, 'minifier' => $minifier]);
408 $result = $this->executeCommand($command);
410 $this->printTaskError($result->getMessage());
411 // the download was not successful
420 // check the success of the conversion
421 if ($result->getExitCode() !== 0) {
422 $this->results['error'][] = $from;
424 $this->results['success'][] = $from;
432 protected function getOS()
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);
447 * @param string $command
449 * @return \Robo\Result
451 protected function executeCommand($command)
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);
462 if (!is_numeric($key)) {
463 array_unshift($a, $key);
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];
470 array_unshift($a, $executable);
471 $command = implode(' ', $a);
473 // execute the command
474 $exec = new Exec($command);
476 return $exec->inflect($this)->printed(false)->run();
480 * @param string $executable
482 * @return \Robo\Result
484 protected function installFromImagemin($executable)
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);
490 return Result::error($this, $message);
492 $this->printTaskInfo('Downloading the {executable} executable from the imagemin repository', ['executable' => $executable]);
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';
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);
514 return Result::error($this, $message);
517 // if it is not windows there is nothing we can do
518 $message = sprintf('Could not download the executable <info>%s</info>', $executable);
520 return Result::error($this, $message);
523 // check if target directory exists
524 if (!is_dir($this->executableTargetDir)) {
525 mkdir($this->executableTargetDir);
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';
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);
537 return Result::error($this, $message);
539 // set the binary to executable
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';
549 $message = sprintf('Executable <info>%s</info> successfully downloaded', $executable);
551 return Result::success($this, $message);
555 * @param string $from
560 protected function optipng($from, $to)
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/
573 * @param string $from
578 protected function jpegtran($from, $to)
580 $command = sprintf('jpegtran -optimize -outfile "%s" "%s"', $to, $from);
585 protected function gifsicle($from, $to)
587 $command = sprintf('gifsicle -o "%s" "%s"', $to, $from);
593 * @param string $from
598 protected function svgo($from, $to)
600 $command = sprintf('svgo "%s" "%s"', $from, $to);
606 * @param string $from
611 protected function pngquant($from, $to)
613 $command = sprintf('pngquant --force --output "%s" "%s"', $to, $from);
619 * @param string $from
624 protected function advpng($from, $to)
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);
634 * @param string $from
639 protected function pngout($from, $to)
641 $command = sprintf('pngout -y -q "%s" "%s"', $from, $to);
647 * @param string $from
652 protected function zopflipng($from, $to)
654 $command = sprintf('zopflipng -y "%s" "%s"', $from, $to);
660 * @param string $from
665 protected function pngcrush($from, $to)
667 $command = sprintf('pngcrush -q -ow "%s" "%s"', $from, $to);
673 * @param string $from
678 protected function jpegoptim($from, $to)
680 // jpegoptim only takes the destination directory as an argument
681 $command = sprintf('jpegoptim --quiet -o --dest "%s" "%s"', dirname($to), $from);
687 * @param string $from
692 protected function jpegRecompress($from, $to)
694 $command = sprintf('jpeg-recompress --quiet "%s" "%s"', $from, $to);
700 * @param string $text
704 public static function camelCase($text)
706 // non-alpha and non-numeric characters become spaces
707 $text = preg_replace('/[^a-z0-9]+/i', ' ', $text);
709 // uppercase the first character of each word
710 $text = ucwords($text);
711 $text = str_replace(" ", "", $text);
712 $text = lcfirst($text);