Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.84% covered (warning)
71.84%
227 / 316
27.27% covered (danger)
27.27%
6 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Cwebp
71.84% covered (warning)
71.84%
227 / 316
27.27% covered (danger)
27.27%
6 / 22
386.27
0.00% covered (danger)
0.00%
0 / 1
 getUnsupportedDefaultOptions
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUniqueOptions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 checkAllHashes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 checkOperationality
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 executeBinary
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 escapeShellArgOnCommandLineOptions
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
7.04
 createCommandLineOptions
90.20% covered (success)
90.20%
46 / 51
0.00% covered (danger)
0.00%
0 / 1
21.42
 checkHashForSuppliedBinary
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
3.07
 getSuppliedBinaryInfoForCurrentOS
72.00% covered (warning)
72.00%
18 / 25
0.00% covered (danger)
0.00%
0 / 1
6.79
 who
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 detectVersion
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
8.64
 detectVersions
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 logBinariesFound
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 logDiscoverAction
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 startTimer
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 readTimer
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getTimeStr
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 logTimeSpent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 discoverCwebpBinaries
82.93% covered (warning)
82.93%
34 / 41
0.00% covered (danger)
0.00%
0 / 1
8.32
 tryCwebpBinary
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
3.85
 composeMeaningfullErrorMessageNoVersionsWorking
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 doActualConvert
76.92% covered (warning)
76.92%
40 / 52
0.00% covered (danger)
0.00%
0 / 1
20.55
1<?php
2
3namespace WebPConvert\Convert\Converters;
4
5use ExecWithFallback\ExecWithFallback;
6use LocateBinaries\LocateBinaries;
7
8use WebPConvert\Convert\Converters\AbstractConverter;
9use WebPConvert\Convert\Converters\BaseTraits\WarningLoggerTrait;
10use WebPConvert\Convert\Converters\ConverterTraits\EncodingAutoTrait;
11use WebPConvert\Convert\Converters\ConverterTraits\ExecTrait;
12use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
13use WebPConvert\Convert\Exceptions\ConversionFailedException;
14use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperationalException;
15use WebPConvert\Options\OptionFactory;
16
17/**
18 * Convert images to webp by calling cwebp binary.
19 *
20 * @package    WebPConvert
21 * @author     Bjørn Rosell <it@rosell.dk>
22 * @since      Class available since Release 2.0.0
23 */
24class Cwebp extends AbstractConverter
25{
26
27    use EncodingAutoTrait;
28    use ExecTrait;
29
30    protected function getUnsupportedDefaultOptions()
31    {
32        return [];
33    }
34
35    /**
36     * Get the options unique for this converter
37     *
38     * @return  array  Array of options
39     */
40    public function getUniqueOptions($imageType)
41    {
42        $binariesForOS = [];
43        if (isset(self::$suppliedBinariesInfo[PHP_OS])) {
44            foreach (self::$suppliedBinariesInfo[PHP_OS] as $i => list($file, $hash, $version)) {
45                $binariesForOS[] = $file;
46            }
47        }
48
49        return OptionFactory::createOptions([
50            self::niceOption(),
51            ['try-cwebp', 'boolean', [
52                'title' => 'Try plain cwebp command',
53                'description' =>
54                    'If set, the converter will try executing "cwebp -version". In case it succeeds, ' .
55                    'and the version is higher than those working cwebps found using other methods, ' .
56                    'the conversion will be done by executing this cwebp.',
57                'default' => true,
58                'ui' => [
59                    'component' => 'checkbox',
60                    'advanced' => true
61                ]
62            ]],
63            ['try-discovering-cwebp', 'boolean', [
64                'title' => 'Try discovering cwebp binary',
65                'description' =>
66                    'If set, the converter will try to discover installed cwebp binaries using a "which -a cwebp" ' .
67                    'command, or in case that fails, a "whereis -b cwebp" command. These commands will find ' .
68                    'cwebp binaries residing in PATH',
69                'default' => true,
70                'ui' => [
71                    'component' => 'checkbox',
72                    'advanced' => true
73                ]
74            ]],
75            ['try-common-system-paths', 'boolean', [
76                'title' => 'Try locating cwebp in common system paths',
77                'description' =>
78                    'If set, the converter will look for a cwebp binaries residing in common system locations ' .
79                    'such as "/usr/bin/cwebp". If such exist, it is assumed that they are valid cwebp binaries. ' .
80                    'A version check will be run on the binaries found (they are executed with the "-version" flag. ' .
81                    'The cwebp with the highest version found using this method and the other enabled methods will ' .
82                    'be used for the actual conversion.' .
83                    'Note: All methods for discovering cwebp binaries are per default enabled. You can save a few ' .
84                    'microseconds by disabling some, but it is probably not worth it, as your ' .
85                    'setup will then become less resilient to system changes.',
86                'default' => true,
87                'ui' => [
88                    'component' => 'checkbox',
89                    'advanced' => true
90                ]
91            ]],
92            ['try-supplied-binary-for-os', 'boolean', [
93                'title' => 'Try precompiled cwebp binaries',
94                'description' =>
95                    'If set, the converter will try use a precompiled cwebp binary that comes with webp-convert. ' .
96                    'But only if it has a higher version that those found by other methods. As the library knows ' .
97                    'the versions of its bundled binaries, no additional time is spent executing them with the ' .
98                    '"-version" parameter. The binaries are hash-checked before executed. ' .
99                    'The library btw. comes with several versions of precompiled cwebps because they have different ' .
100                    'dependencies - some works on some systems and others on others.',
101                'default' => true,
102                'ui' => [
103                    'component' => 'checkbox',
104                    'advanced' => true
105                ]
106            ]],
107            ['skip-these-precompiled-binaries', 'string', [
108              'title' => 'Skip these precompiled binaries',
109                  'description' =>
110                      '',
111                  'default' => '',
112                  'ui' => [
113                      'component' => 'multi-select',
114                      'advanced' => true,
115                      'options' => $binariesForOS,
116                      'display' => "option('cwebp-try-supplied-binary-for-os') == true"
117                  ]
118
119            ]],
120            ['rel-path-to-precompiled-binaries', 'string', [
121              'title' => 'Rel path to precompiled binaries',
122                  'description' =>
123                      '',
124                  'default' => './Binaries',
125                  'ui' => [
126                      'component' => '',
127                      'advanced' => true,
128                      'display' => "option('cwebp-try-supplied-binary-for-os') == true"
129                  ],
130                  'sensitive' => true
131            ]],
132            ['command-line-options', 'string', [
133              'title' => 'Command line options',
134                  'description' =>
135                      '',
136                  'default' => '',
137                  'ui' => [
138                      'component' => 'input',
139                      'advanced' => true,
140                  ]
141
142            ]],
143        ]);
144    }
145
146
147    // OS-specific binaries included in this library, along with hashes
148    // If other binaries are going to be added, notice that the first argument is what PHP_OS returns.
149    // (possible values, see here: https://stackoverflow.com/questions/738823/possible-values-for-php-os)
150    // Got the precompiled binaries here: https://developers.google.com/speed/webp/docs/precompiled
151    // Note when changing binaries:
152    // 1: Do NOT use "." in filename. It causes unzipping to fail on some hosts
153    // 2: Set permission to 775. 755 causes unzipping to fail on some hosts
154    private static $suppliedBinariesInfo = [
155        'WINNT' => [
156            ['cwebp-120-windows-x64.exe', '2849fd06012a9eb311b02a4f8918ae4b16775693bc21e95f4cc6a382eac299f9', '1.2.0'],
157
158            // Keep the 1.1.0 version a while, in case some may have problems with the 1.2.0 version
159            ['cwebp-110-windows-x64.exe', '442682869402f92ad2c8b3186c02b0ea6d6da68d2f908df38bf905b3411eb9fb', '1.1.0'],
160        ],
161        'Darwin' => [
162            ['cwebp-110-mac-10_15', 'bfce742da09b959f9f2929ba808fed9ade25c8025530434b6a47d217a6d2ceb5', '1.1.0'],
163        ],
164        'SunOS' => [
165            // Got this from ewww Wordpress plugin, which unfortunately still uses the old 0.6.0 versions
166            // Can you help me get a 1.0.3 version?
167            ['cwebp-060-solaris', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f', '0.6.0']
168        ],
169        'FreeBSD' => [
170            // Got this from ewww Wordpress plugin, which unfortunately still uses the old 0.6.0 versions
171            // Can you help me get a 1.0.3 version?
172            ['cwebp-060-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573', '0.6.0']
173        ],
174        'Linux' => [
175
176            // PS: Some experience the following error with 1.20:
177            // /lib/x86_64-linux-gnu/libm.so.6: version `GLIBC_2.29' not found
178            // (see #278)
179
180            ['cwebp-120-linux-x86-64', 'f1b7dc03e95535a6b65852de07c0404be4dba078af48369f434ee39b2abf8f4e', '1.2.0'],
181
182            // As some experience the an error with 1.20 (see #278), we keep the 1.10
183            ['cwebp-110-linux-x86-64', '1603b07b592876dd9fdaa62b44aead800234c9474ff26dc7dd01bc0f4785c9c6', '1.1.0'],
184
185            // Statically linked executable
186            // It may be that it on some systems works, where the dynamically linked does not (see #196)
187            [
188                'cwebp-103-linux-x86-64-static',
189                'ab96f01b49336da8b976c498528080ff614112d5985da69943b48e0cb1c5228a',
190                '1.0.3'
191            ],
192
193            // Old executable for systems in case all of the above fails
194            ['cwebp-061-linux-x86-64', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568', '0.6.1'],
195        ]
196    ];
197
198    /**
199     *  Check all hashes of the precompiled binaries.
200     *
201     *  This isn't used when converting, but can be used as a startup check.
202     */
203    public static function checkAllHashes()
204    {
205        foreach (self::$suppliedBinariesInfo as $os => $arr) {
206            foreach ($arr as $i => list($filename, $expectedHash)) {
207                $actualHash = hash_file("sha256", __DIR__ . '/Binaries/' . $filename);
208                if ($expectedHash != $actualHash) {
209                    throw new \Exception(
210                        'Hash for ' . $filename . ' is incorrect! ' .
211                        'Checksum is: ' . $actualHash . ', ' .
212                        ', but expected: ' . $expectedHash .
213                        '. Did you transfer with FTP, but not in binary mode? '
214                    );
215                }
216            }
217        }
218    }
219
220    public function checkOperationality()
221    {
222        $this->checkOperationalityExecTrait();
223
224        $options = $this->options;
225        if (!$options['try-supplied-binary-for-os'] &&
226            !$options['try-common-system-paths'] &&
227            !$options['try-cwebp'] &&
228            !$options['try-discovering-cwebp']
229        ) {
230            throw new ConverterNotOperationalException(
231                'Configured to neither try pure cwebp command, ' .
232                'nor look for cweb binaries in common system locations and ' .
233                'nor to use one of the supplied precompiled binaries. ' .
234                'But these are the only ways this converter can convert images. No conversion can be made!'
235            );
236        }
237    }
238
239    private function executeBinary($binary, $commandOptions, $useNice)
240    {
241        //$version = $this->detectVersion($binary);
242
243        // Redirect stderr to same place as stdout with "2>&1"
244        // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/
245
246        $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions . ' 2>&1';
247
248        //$logger->logLn('command options:' . $commandOptions);
249        $this->logLn('Trying to convert by executing the following command:');
250        $startExecuteBinaryTime = self::startTimer();
251        ;
252        $this->logLn($command);
253        ExecWithFallback::exec($command, $output, $returnCode);
254        $this->logExecOutput($output);
255        $this->logTimeSpent($startExecuteBinaryTime, 'Executing cwebp binary took: ');
256        $this->logLn('');
257        /*
258        if ($returnCode == 255) {
259            if (isset($output[0])) {
260                // Could be an error like 'Error! Cannot open output file' or 'Error! ...preset... '
261                $this->logLn(print_r($output[0], true));
262            }
263        }*/
264        //$logger->logLn(self::msgForExitCode($returnCode));
265        return intval($returnCode);
266    }
267
268    /**
269     *  Use "escapeshellarg()" on all arguments in a commandline string of options
270     *
271     *  For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in:
272     *  [
273     *    "-sharpness '5'"
274     *    "-crop '10' '10' '40' '40'"
275     *    "-low_memory"
276     *  ]
277     * @param  string $commandLineOptions  string which can contain multiple commandline options
278     * @return array  Array of command options
279     */
280    private static function escapeShellArgOnCommandLineOptions($commandLineOptions)
281    {
282        if (!ctype_print($commandLineOptions)) {
283            throw new ConversionFailedException(
284                'Non-printable characters are not allowed in the extra command line options'
285            );
286        }
287
288        if (preg_match('#[^a-zA-Z0-9_\s\-]#', $commandLineOptions)) {
289            throw new ConversionFailedException('The extra command line options contains inacceptable characters');
290        }
291
292        $cmdOptions = [];
293        $arr = explode(' -', ' ' . $commandLineOptions);
294        foreach ($arr as $cmdOption) {
295            $pos = strpos($cmdOption, ' ');
296            $cName = '';
297            if (!$pos) {
298                $cName = $cmdOption;
299                if ($cName == '') {
300                    continue;
301                }
302                $cmdOptions[] = '-' . $cName;
303            } else {
304                $cName = substr($cmdOption, 0, $pos);
305                $cValues = substr($cmdOption, $pos + 1);
306                $cValuesArr = explode(' ', $cValues);
307                foreach ($cValuesArr as &$cArg) {
308                    $cArg = escapeshellarg($cArg);
309                }
310                $cValues = implode(' ', $cValuesArr);
311                $cmdOptions[] = '-' . $cName . ' ' . $cValues;
312            }
313        }
314        return $cmdOptions;
315    }
316
317    /**
318     * Build command line options for a given version of cwebp.
319     *
320     * The "-near_lossless" param is not supported on older versions of cwebp, so skip on those.
321     *
322     * @param  string $version  Version of cwebp (ie "1.0.3")
323     * @return string
324     */
325    private function createCommandLineOptions($version)
326    {
327
328        $this->logLn('Creating command line options for version: ' . $version);
329
330        // we only need two decimal places for version.
331        // convert to number to make it easier to compare
332        $version = preg_match('#^\d+\.\d+#', $version, $matches);
333        $versionNum = 0;
334        if (isset($matches[0])) {
335            $versionNum = floatval($matches[0]);
336        } else {
337            $this->logLn(
338                'Could not extract version number from the following version string: ' . $version,
339                'bold'
340            );
341        }
342
343        //$this->logLn('version:' . strval($versionNum));
344
345        $options = $this->options;
346
347        $cmdOptions = [];
348
349        // Metadata (all, exif, icc, xmp or none (default))
350        // Comma-separated list of existing metadata to copy from input to output
351        if ($versionNum >= 0.3) {
352            $cmdOptions[] = '-metadata ' . $options['metadata'];
353        } else {
354            $this->logLn('Ignoring metadata option (requires cwebp 0.3)', 'italic');
355        }
356
357        // preset. Appears first in the list as recommended in the docs
358        if (!is_null($options['preset'])) {
359            if ($options['preset'] != 'none') {
360                $cmdOptions[] = '-preset ' . escapeshellarg($options['preset']);
361            }
362        }
363
364        // Size
365        $addedSizeOption = false;
366        if (!is_null($options['size-in-percentage'])) {
367            $sizeSource = filesize($this->source);
368            if ($sizeSource !== false) {
369                $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100);
370                $cmdOptions[] = '-size ' . $targetSize;
371                $addedSizeOption = true;
372            }
373        }
374
375        // quality
376        if (!$addedSizeOption) {
377            $cmdOptions[] = '-q ' . $this->getCalculatedQuality();
378        }
379
380        // alpha-quality
381        if ($this->options['alpha-quality'] !== 100) {
382            $cmdOptions[] = '-alpha_q ' . escapeshellarg($this->options['alpha-quality']);
383        }
384
385        // Losless PNG conversion
386        if ($options['encoding'] == 'lossless') {
387            // No need to add -lossless when near-lossless is used (on version >= 0.5)
388            if (($options['near-lossless'] === 100) || ($versionNum < 0.5)) {
389                $cmdOptions[] = '-lossless';
390            }
391        }
392
393        // Near-lossles
394        if ($options['near-lossless'] !== 100) {
395            if ($versionNum < 0.5) {
396                $this->logLn('Ignoring near-lossless option (requires cwebp 0.5)', 'italic');
397            } else {
398                // The "-near_lossless" flag triggers lossless encoding. We don't want that to happen,
399                // we want the "encoding" option to be respected, and we need it to be in order for
400                // encoding=auto to work.
401                // So: Only set when "encoding" is set to "lossless"
402                if ($options['encoding'] == 'lossless') {
403                    $cmdOptions[] = '-near_lossless ' . $options['near-lossless'];
404                } else {
405                    $this->logLn(
406                        'The near-lossless option ignored for lossy'
407                    );
408                }
409            }
410        }
411
412        // Autofilter
413        if ($options['auto-filter'] === true) {
414            $cmdOptions[] = '-af';
415        }
416
417        // SharpYUV
418        if ($options['sharp-yuv'] === true) {
419            if ($versionNum >= 0.6) {  // #284
420                $cmdOptions[] = '-sharp_yuv';
421            } else {
422                $this->logLn('Ignoring sharp-yuv option (requires cwebp 0.6)', 'italic');
423            }
424        }
425
426
427        // Built-in method option
428        $cmdOptions[] = '-m ' . strval($options['method']);
429
430        // Built-in low memory option
431        if ($options['low-memory']) {
432            $cmdOptions[] = '-low_memory';
433        }
434
435        // command-line-options
436        if ($options['command-line-options']) {
437            /*
438            In some years, we can use the splat instead (requires PHP 5.6)
439            array_push(
440                $cmdOptions,
441                ...self::escapeShellArgOnCommandLineOptions($options['command-line-options'])
442            );
443            */
444            foreach (self::escapeShellArgOnCommandLineOptions($options['command-line-options']) as $cmdLineOption) {
445                array_push($cmdOptions, $cmdLineOption);
446            }
447        }
448
449        // Source file
450        $cmdOptions[] = escapeshellarg($this->source);
451
452        // Output
453        $cmdOptions[] = '-o ' . escapeshellarg($this->destination);
454
455        $commandOptions = implode(' ', $cmdOptions);
456        //$this->logLn('command line options:' . $commandOptions);
457
458        return $commandOptions;
459    }
460
461    private function checkHashForSuppliedBinary($binaryFile, $hash)
462    {
463        // File exists, now generate its hash
464        // hash_file() is normally available, but it is not always
465        // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash
466        // If available, validate that hash is correct.
467
468        if (function_exists('hash_file')) {
469            $this->logLn(
470                'Checking checksum for supplied binary: ' . $binaryFile
471            );
472            $startHashCheckTime = self::startTimer();
473
474            $binaryHash = hash_file('sha256', $binaryFile);
475
476            if ($binaryHash != $hash) {
477                $this->logLn(
478                    'Binary checksum of supplied binary is invalid! ' .
479                    'Did you transfer with FTP, but not in binary mode? ' .
480                    'File:' . $binaryFile . '. ' .
481                    'Expected checksum: ' . $hash . '. ' .
482                    'Actual checksum:' . $binaryHash . '.',
483                    'bold'
484                );
485                return false;
486                ;
487            }
488
489            $this->logTimeSpent($startHashCheckTime, 'Checksum test took: ');
490        }
491        return true;
492    }
493
494    /**
495     *  Get supplied binary info for current OS.
496     *  paths are made absolute and checked. Missing are removed
497     *
498     *  @return  array  Two arrays.
499     *                  First array:  array of files (absolute paths)
500     *                  Second array: array of info objects (absolute path, hash and version)
501     */
502    private function getSuppliedBinaryInfoForCurrentOS()
503    {
504        $this->log('Checking if we have a supplied precompiled binary for your OS (' . PHP_OS . ')... ');
505
506        // Try supplied binary (if available for OS, and hash is correct)
507        $options = $this->options;
508        if (!isset(self::$suppliedBinariesInfo[PHP_OS])) {
509            $this->logLn('No we dont - not for that OS');
510            return [];
511        }
512
513        $filesFound = [];
514        $info = [];
515        $files = self::$suppliedBinariesInfo[PHP_OS];
516        if (count($files) == 1) {
517            $this->logLn('We do.');
518        } else {
519            $this->logLn('We do. We in fact have ' . count($files));
520        }
521
522        $skipThese = explode(',', $this->options['skip-these-precompiled-binaries']);
523
524        //$this->logLn('However, skipping' . print_r($skipThese, true));
525
526        foreach ($files as $i => list($file, $hash, $version)) {
527            //$file = $info[0];
528            //$hash = $info[1];
529
530            $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file;
531
532            // Replace "/./" with "/" in path (we could alternatively use realpath)
533            //$binaryFile = preg_replace('#\/\.\/#', '/', $binaryFile);
534            // The file should exist, but may have been removed manually.
535            /*
536            if (!file_exists($binaryFile)) {
537                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
538                return false;
539            }*/
540            if (in_array($file, $skipThese)) {
541                $this->logLn('Skipped: ' . $file . ' (was told to in the "skip-these-precompiled-binaries" option)');
542                continue;
543            }
544
545
546            $realPathResult = realpath($binaryFile);
547            if ($realPathResult === false) {
548                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
549                continue;
550            }
551            $binaryFile = $realPathResult;
552            $filesFound[] = $realPathResult;
553            $info[] = [$realPathResult, $hash, $version, $file];
554        }
555        return [$filesFound, $info];
556    }
557
558    private function who()
559    {
560        ExecWithFallback::exec('whoami 2>&1', $whoOutput, $whoReturnCode);
561        if (($whoReturnCode == 0) && (isset($whoOutput[0]))) {
562            return 'user: "' . $whoOutput[0] . '"';
563        } else {
564            return 'the user that the command was run with';
565        }
566    }
567
568    /**
569     * Detect the version of a cwebp binary.
570     *
571     * @param string $binary  The binary to detect version for (path to cwebp or simply "cwebp")
572     *
573     * @return  string|int  Version string (ie "1.0.2") OR return code, in case of failure
574     */
575    private function detectVersion($binary)
576    {
577        $command = $binary . ' -version 2>&1';
578        $this->log('- Executing: ' . $command);
579        ExecWithFallback::exec($command, $output, $returnCode);
580
581        if ($returnCode == 0) {
582            if (isset($output[0])) {
583                $this->logLn('. Result: version: *' . $output[0] . '*');
584                return $output[0];
585            }
586        } else {
587            $this->log('. Result: ');
588            if ($returnCode == 127) {
589                $this->logLn(
590                    '*Exec failed* (the cwebp binary was not found at path: ' . $binary .
591                    ', or it had missing library dependencies)'
592                );
593            } else {
594                if ($returnCode == 126) {
595                    $this->logLn(
596                        '*Exec failed*. ' .
597                        'Permission denied (' . $this->who() . ' does not have permission to execute that binary)'
598                    );
599                } else {
600                    $this->logLn(
601                        '*Exec failed* (return code: ' . $returnCode . ')'
602                    );
603                    $this->logExecOutput($output);
604                }
605            }
606            return $returnCode;
607        }
608        return ''; // Will not happen. Just so phpstan doesn't complain
609    }
610
611    /**
612     * Check versions for an array of binaries.
613     *
614     * @param  array  $binaries  array of binaries to detect the version of
615     *
616     * @return  array  the "detected" key holds working binaries and their version numbers, the
617     *                  the "failed" key holds failed binaries and their error codes.
618     */
619    private function detectVersions($binaries)
620    {
621        $binariesWithVersions = [];
622        $binariesWithFailCodes = [];
623
624        foreach ($binaries as $binary) {
625            $versionStringOrFailCode = $this->detectVersion($binary);
626        //    $this->logLn($binary . ': ' . $versionString);
627            if (gettype($versionStringOrFailCode) == 'string') {
628                $binariesWithVersions[$binary] = $versionStringOrFailCode;
629            } else {
630                $binariesWithFailCodes[$binary] = $versionStringOrFailCode;
631            }
632        }
633        return ['detected' => $binariesWithVersions, 'failed' => $binariesWithFailCodes];
634    }
635
636    private function logBinariesFound($binaries, $startTime)
637    {
638        if (count($binaries) == 0) {
639            $this->logLn('Found 0 binaries' . self::getTimeStr($startTime));
640        } else {
641            $this->logLn('Found ' . count($binaries) . ' binaries' . self::getTimeStr($startTime));
642            foreach ($binaries as $binary) {
643                $this->logLn('- ' . $binary);
644            }
645        }
646    }
647
648    private function logDiscoverAction($optionName, $description)
649    {
650        if ($this->options[$optionName]) {
651            $this->logLn(
652                'Discovering binaries ' . $description . ' ' .
653                '(to skip this step, disable the "' . $optionName . '" option)'
654            );
655        } else {
656            $this->logLn(
657                'Skipped discovering binaries ' . $description . ' ' .
658                '(enable "' . $optionName . '" if you do not want to skip that step)'
659            );
660        }
661    }
662
663    private static function startTimer()
664    {
665        if (function_exists('microtime')) {
666            return microtime(true);
667        } else {
668            return 0;
669        }
670    }
671
672    private static function readTimer($startTime)
673    {
674        if (function_exists('microtime')) {
675            $endTime = microtime(true);
676            $seconds = ($endTime - $startTime);
677            return round(($seconds * 1000));
678        } else {
679            return 0;
680        }
681    }
682
683    private static function getTimeStr($startTime, $pre = ' (spent ', $post = ')')
684    {
685        if (function_exists('microtime')) {
686            $ms = self::readTimer($startTime);
687            return $pre . $ms . ' ms' . $post;
688        }
689        return '';
690    }
691
692    private function logTimeSpent($startTime, $pre = 'Spent: ')
693    {
694        if (function_exists('microtime')) {
695            $ms = self::readTimer($startTime);
696            $this->logLn($pre . $ms . ' ms');
697        }
698    }
699
700    /**
701     *  @return array   Two arrays (in an array).
702     *                  First array: binaries found,
703     *                  Second array: supplied binaries info for current OS
704     */
705    private function discoverCwebpBinaries()
706    {
707        $this->logLn(
708            'Looking for cwebp binaries.'
709        );
710
711        $startDiscoveryTime = self::startTimer();
712
713        $binaries = [];
714
715        if (defined('WEBPCONVERT_CWEBP_PATH')) {
716            $this->logLn('WEBPCONVERT_CWEBP_PATH was defined, so using that path and ignoring any other');
717            return [[constant('WEBPCONVERT_CWEBP_PATH')],[[], []]];
718        }
719        if (!empty(getenv('WEBPCONVERT_CWEBP_PATH'))) {
720            $this->logLn(
721                'WEBPCONVERT_CWEBP_PATH environment variable was set, so using that path and ignoring any other'
722            );
723            return [[getenv('WEBPCONVERT_CWEBP_PATH')],[[], []]];
724        }
725
726        if ($this->options['try-cwebp']) {
727            $startTime = self::startTimer();
728            $this->logLn(
729                'Discovering if a plain cwebp call works (to skip this step, disable the "try-cwebp" option)'
730            );
731            $result = $this->detectVersion('cwebp');
732            if (gettype($result) == 'string') {
733                $this->logLn('We could get the version, so yes, a plain cwebp call works ' .
734                '(spent ' . self::readTimer($startTime) . ' ms)');
735                $binaries[] = 'cwebp';
736            } else {
737                $this->logLn('Nope a plain cwebp call does not work' . self::getTimeStr($startTime));
738            }
739        } else {
740            $this->logLn(
741                'Skipped discovering if a plain cwebp call works' .
742                ' (enable the "try-cwebp" option if you do not want to skip that step)'
743            );
744        }
745
746        // try-discovering-cwebp
747        $startTime = self::startTimer();
748        $this->logDiscoverAction('try-discovering-cwebp', 'using "which -a cwebp" command.');
749        if ($this->options['try-discovering-cwebp']) {
750            $moreBinaries = LocateBinaries::locateInstalledBinaries('cwebp');
751            $this->logBinariesFound($moreBinaries, $startTime);
752            $binaries = array_merge($binaries, $moreBinaries);
753        }
754
755        // 'try-common-system-paths'
756        $startTime = self::startTimer();
757        $this->logDiscoverAction('try-common-system-paths', 'by peeking in common system paths');
758        if ($this->options['try-common-system-paths']) {
759            $moreBinaries = LocateBinaries::locateInCommonSystemPaths('cwebp');
760            $this->logBinariesFound($moreBinaries, $startTime);
761            $binaries = array_merge($binaries, $moreBinaries);
762        }
763
764        // try-supplied-binary-for-os
765        $suppliedBinariesInfo = [[], []];
766        $startTime = self::startTimer();
767        $this->logDiscoverAction('try-supplied-binary-for-os', 'which are distributed with the webp-convert library');
768        if ($this->options['try-supplied-binary-for-os']) {
769            $suppliedBinariesInfo = $this->getSuppliedBinaryInfoForCurrentOS();
770            $moreBinaries = $suppliedBinariesInfo[0];
771            $this->logBinariesFound($moreBinaries, $startTime);
772            //$binaries = array_merge($binaries, $moreBinaries);
773        }
774
775        $this->logTimeSpent($startDiscoveryTime, 'Discovering cwebp binaries took: ');
776        $this->logLn('');
777
778        return [array_values(array_unique($binaries)), $suppliedBinariesInfo];
779    }
780
781    /**
782     * Try executing a cwebp binary (or command, like: "cwebp")
783     *
784     * @param  string  $binary
785     * @param  string  $version  Version of cwebp (ie "1.0.3")
786     * @param  boolean $useNice  Whether to use "nice" command or not
787     *
788     * @return boolean  success or not.
789     */
790    private function tryCwebpBinary($binary, $version, $useNice)
791    {
792
793        //$this->logLn('Trying binary: ' . $binary);
794        $commandOptions = $this->createCommandLineOptions($version);
795
796        $returnCode = $this->executeBinary($binary, $commandOptions, $useNice);
797        if ($returnCode == 0) {
798            // It has happened that even with return code 0, there was no file at destination.
799            if (!file_exists($this->destination)) {
800                $this->logLn('executing cweb returned success code - but no file was found at destination!');
801                return false;
802            } else {
803                $this->logLn('Success');
804                return true;
805            }
806        } else {
807            $this->logLn(
808                'Exec failed (return code: ' . $returnCode . ')'
809            );
810            return false;
811        }
812    }
813
814    /**
815     *  Helper for composing an error message when no converters are working.
816     *
817     *  @param  array  $versions  The array which we get from calling ::detectVersions($binaries)
818     *  @return string  An informative and to the point error message.
819     */
820    private function composeMeaningfullErrorMessageNoVersionsWorking($versions)
821    {
822        // TODO: Take "supplied" into account
823
824        // PS: array_values() is used to reindex
825        $uniqueFailCodes = array_values(array_unique(array_values($versions['failed'])));
826        $justOne = (count($versions['failed']) == 1);
827
828        if (count($uniqueFailCodes) == 1) {
829            if ($uniqueFailCodes[0] == 127) {
830                return 'No cwebp binaries located. Check the conversion log for details.';
831            }
832        }
833        // If there are more failures than 127, the 127 failures are unintesting.
834        // It is to be expected that some of the common system paths does not contain a cwebp.
835        $uniqueFailCodesBesides127 = array_values(array_diff($uniqueFailCodes, [127]));
836
837        if (count($uniqueFailCodesBesides127) == 1) {
838            if ($uniqueFailCodesBesides127[0] == 126) {
839                return 'No cwebp binaries could be executed (permission denied for ' . $this->who() . ').';
840            }
841        }
842
843        $errorMsg = '';
844        if ($justOne) {
845            $errorMsg .= 'The cwebp file found cannot be can be executed ';
846        } else {
847            $errorMsg .= 'None of the cwebp files can be executed ';
848        }
849        if (count($uniqueFailCodesBesides127) == 1) {
850            $errorMsg .= '(failure code: ' . $uniqueFailCodesBesides127[0] . ')';
851        } else {
852            $errorMsg .= '(failure codes: ' . implode(', ', $uniqueFailCodesBesides127) . ')';
853        }
854        return $errorMsg;
855    }
856
857    protected function doActualConvert()
858    {
859        list($foundBinaries, $suppliedBinariesInfo) = $this->discoverCwebpBinaries();
860        $suppliedBinaries = $suppliedBinariesInfo[0];
861        $allBinaries = array_merge($foundBinaries, $suppliedBinaries);
862
863        //$binaries = $this->discoverCwebpBinaries();
864        if (count($allBinaries) == 0) {
865            $this->logLn('No cwebp binaries found!');
866
867            $discoverOptions = [
868                'try-supplied-binary-for-os',
869                'try-common-system-paths',
870                'try-cwebp',
871                'try-discovering-cwebp'
872            ];
873            $disabledDiscoverOptions = [];
874            foreach ($discoverOptions as $discoverOption) {
875                if (!$this->options[$discoverOption]) {
876                    $disabledDiscoverOptions[] = $discoverOption;
877                }
878            }
879            if (count($disabledDiscoverOptions) == 0) {
880                throw new SystemRequirementsNotMetException(
881                    'No cwebp binaries found.'
882                );
883            } else {
884                throw new SystemRequirementsNotMetException(
885                    'No cwebp binaries found. Try enabling the "' .
886                    implode('" option or the "', $disabledDiscoverOptions) . '" option.'
887                );
888            }
889        }
890
891        $detectedVersions = [];
892        if (count($foundBinaries) > 0) {
893            $this->logLn(
894                'Detecting versions of the cwebp binaries found' .
895                (count($suppliedBinaries) > 0 ? ' (except supplied binaries)' : '.')
896            );
897            $startDetectionTime = self::startTimer();
898            $versions = $this->detectVersions($foundBinaries);
899            $detectedVersions = $versions['detected'];
900
901            $this->logTimeSpent($startDetectionTime, 'Detecting versions took: ');
902        }
903
904        //$suppliedVersions = [];
905        $suppliedBinariesHash = [];
906        $suppliedBinariesFilename = [];
907
908        $binaryVersions = $detectedVersions;
909        foreach ($suppliedBinariesInfo[1] as list($path, $hash, $version, $filename)) {
910            $binaryVersions[$path] = $version;
911            $suppliedBinariesHash[$path] = $hash;
912            $suppliedBinariesFilename[$path] = $filename;
913        }
914
915        //$binaryVersions = array_merge($detectedVersions, $suppliedBinariesInfo);
916
917        // TODO: reimplement
918        /*
919        $versions['supplied'] = $suppliedBinariesInfo;
920
921        $binaryVersions = $versions['detected'];
922        if ((count($binaryVersions) == 0) && (count($suppliedBinaries) == 0)) {
923            // No working cwebp binaries found, no supplied binaries found
924
925            throw new SystemRequirementsNotMetException(
926                $this->composeMeaningfullErrorMessageNoVersionsWorking($versions)
927            );
928        }*/
929
930        // Sort binaries so those with highest numbers comes first
931        arsort($binaryVersions);
932        $this->logLn(
933            'Binaries ordered by version number.'
934        );
935        foreach ($binaryVersions as $binary => $version) {
936            $this->logLn('- ' . $binary . ': (version: ' . $version . ')');
937        }
938
939        // Execute!
940        $this->logLn(
941            'Starting conversion, using the first of these. If that should fail, ' .
942            'the next will be tried and so on.'
943        );
944        $useNice = ($this->options['use-nice'] && $this->checkNiceSupport());
945
946        $success = false;
947        foreach ($binaryVersions as $binary => $version) {
948            if (isset($suppliedBinariesHash[$binary])) {
949                if (!$this->checkHashForSuppliedBinary($binary, $suppliedBinariesHash[$binary])) {
950                    continue;
951                }
952            }
953            if ($this->tryCwebpBinary($binary, $version, $useNice)) {
954                $success = true;
955                break;
956            } else {
957                if (isset($suppliedBinariesFilename[$binary])) {
958                    $this->logLn(
959                        'Note: You can prevent trying this precompiled binary, by setting the ' .
960                        '"skip-these-precompiled-binaries" option to "' . $suppliedBinariesFilename[$binary] . '"'
961                    );
962                }
963            }
964        }
965
966        // cwebp sets file permissions to 664 but instead ..
967        // .. $this->source file permissions should be used
968
969        if ($success) {
970            $fileStatistics = stat($this->source);
971            if ($fileStatistics !== false) {
972                // Apply same permissions as source file, but strip off the executable bits
973                $permissions = $fileStatistics['mode'] & 0000666;
974                chmod($this->destination, $permissions);
975            }
976        } else {
977            throw new SystemRequirementsNotMetException('Failed converting. Check the conversion log for details.');
978        }
979    }
980}