Completed
Push — master ( b28ce7...3bde8c )
by Bjørn
02:58
created

Cwebp::discoverBinaries()   B

Complexity

Conditions 10
Paths 14

Size

Total Lines 45
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 12.9132

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 10
eloc 27
c 2
b 0
f 0
nc 14
nop 0
dl 0
loc 45
ccs 18
cts 26
cp 0.6923
crap 12.9132
rs 7.6666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace WebPConvert\Convert\Converters;
4
5
use WebPConvert\Convert\Converters\AbstractConverter;
6
use WebPConvert\Convert\Converters\ConverterTraits\EncodingAutoTrait;
7
use WebPConvert\Convert\Converters\ConverterTraits\ExecTrait;
8
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
9
use WebPConvert\Convert\Exceptions\ConversionFailedException;
10
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperationalException;
11
use WebPConvert\Options\BooleanOption;
12
use WebPConvert\Options\SensitiveStringOption;
13
use WebPConvert\Options\StringOption;
14
15
/**
16
 * Convert images to webp by calling cwebp binary.
17
 *
18
 * @package    WebPConvert
19
 * @author     Bjørn Rosell <[email protected]>
20
 * @since      Class available since Release 2.0.0
21
 */
22
class Cwebp extends AbstractConverter
23
{
24
25
    use EncodingAutoTrait;
26
    use ExecTrait;
27
28
    protected function getUnsupportedDefaultOptions()
29
    {
30
        return [];
31
    }
32
33 8
    protected function createOptions()
34
    {
35 8
        parent::createOptions();
36
37 8
        $this->options2->addOptions(
38 8
            new StringOption('command-line-options', ''),
39 8
            new SensitiveStringOption('rel-path-to-precompiled-binaries', './Binaries'),
40 8
            new BooleanOption('try-common-system-paths', true),
41 8
            new BooleanOption('try-supplied-binary-for-os', true)
42
        );
43 8
    }
44
45
    // System paths to look for cwebp binary
46
    private static $cwebpDefaultPaths = [
47
        'cwebp',
48
        '/usr/bin/cwebp',
49
        '/usr/local/bin/cwebp',
50
        '/usr/gnu/bin/cwebp',
51
        '/usr/syno/bin/cwebp'
52
    ];
53
54
    // OS-specific binaries included in this library, along with hashes
55
    // If other binaries are going to be added, notice that the first argument is what PHP_OS returns.
56
    // (possible values, see here: https://stackoverflow.com/questions/738823/possible-values-for-php-os)
57
    // Got the precompiled binaries here: https://developers.google.com/speed/webp/docs/precompiled
58
    private static $suppliedBinariesInfo = [
59
        'WINNT' => [['cwebp.exe', '49e9cb98db30bfa27936933e6fd94d407e0386802cb192800d9fd824f6476873']],
60
        'Darwin' => [['cwebp-mac12', 'a06a3ee436e375c89dbc1b0b2e8bd7729a55139ae072ed3f7bd2e07de0ebb379']],
61
        'SunOS' => [['cwebp-sol', '1febaffbb18e52dc2c524cda9eefd00c6db95bc388732868999c0f48deb73b4f']],
62
        'FreeBSD' => [['cwebp-fbsd', 'e5cbea11c97fadffe221fdf57c093c19af2737e4bbd2cb3cd5e908de64286573']],
63
        'Linux' => [
64
            // Dynamically linked executable.
65
            // It seems it is slightly faster than the statically linked
66
            ['cwebp-linux-1.0.2-shared', 'd6142e9da2f1cab541de10a31527c597225fff5644e66e31d62bb391c41bfbf4'],
67
68
            // Statically linked executable
69
            // It may be that it on some systems works, where the dynamically linked does not (see #196)
70
            ['cwebp-linux-1.0.2-static', 'a67092563d9de0fbced7dde61b521d60d10c0ad613327a42a81845aefa612b29'],
71
72
            // Old executable for systems where both of the above fails
73
            ['cwebp-linux-0.6.1', '916623e5e9183237c851374d969aebdb96e0edc0692ab7937b95ea67dc3b2568'],
74
        ]
75
    ];
76
77 3
    public function checkOperationality()
78
    {
79 3
        $this->checkOperationalityExecTrait();
80
81 3
        $options = $this->options;
82 3
        if (!$options['try-supplied-binary-for-os'] && !$options['try-common-system-paths']) {
83 1
            throw new ConverterNotOperationalException(
84
                'Configured to neither look for cweb binaries in common system locations, ' .
85
                'nor to use one of the supplied precompiled binaries. But these are the only ways ' .
86 1
                'this converter can convert images. No conversion can be made!'
87
            );
88
        }
89 2
    }
90
91 2
    private function executeBinary($binary, $commandOptions, $useNice)
92
    {
93
        //$version = $this->detectVersion($binary);
94
95 2
        $command = ($useNice ? 'nice ' : '') . $binary . ' ' . $commandOptions;
96
97
        //$logger->logLn('command options:' . $commandOptions);
98 2
        $this->logLn('Trying to convert by executing the following command:');
99 2
        $this->logLn($command);
100 2
        exec($command, $output, $returnCode);
101 2
        $this->logExecOutput($output);
102
        /*
103
        if ($returnCode == 255) {
104
            if (isset($output[0])) {
105
                // Could be an error like 'Error! Cannot open output file' or 'Error! ...preset... '
106
                $this->logLn(print_r($output[0], true));
107
            }
108
        }*/
109
        //$logger->logLn(self::msgForExitCode($returnCode));
110 2
        return intval($returnCode);
111
    }
112
113
    /**
114
     *  Use "escapeshellarg()" on all arguments in a commandline string of options
115
     *
116
     *  For example, passing '-sharpness 5 -crop 10 10 40 40 -low_memory' will result in:
117
     *  [
118
     *    "-sharpness '5'"
119
     *    "-crop '10' '10' '40' '40'"
120
     *    "-low_memory"
121
     *  ]
122
     * @param  string $commandLineOptions  string which can contain multiple commandline options
123
     * @return array  Array of command options
124
     */
125 1
    private static function escapeShellArgOnCommandLineOptions($commandLineOptions)
126
    {
127 1
        if (!ctype_print($commandLineOptions)) {
128
            throw new ConversionFailedException(
129
                'Non-printable characters are not allowed in the extra command line options'
130
            );
131
        }
132
133 1
        if (preg_match('#[^a-zA-Z0-9_\s\-]#', $commandLineOptions)) {
134
            throw new ConversionFailedException('The extra command line options contains inacceptable characters');
135
        }
136
137 1
        $cmdOptions = [];
138 1
        $arr = explode(' -', ' ' . $commandLineOptions);
139 1
        foreach ($arr as $cmdOption) {
140 1
            $pos = strpos($cmdOption, ' ');
141 1
            $cName = '';
142 1
            if (!$pos) {
143 1
                $cName = $cmdOption;
144 1
                if ($cName == '') {
145 1
                    continue;
146
                }
147 1
                $cmdOptions[] = '-' . $cName;
148
            } else {
149 1
                $cName = substr($cmdOption, 0, $pos);
150 1
                $cValues = substr($cmdOption, $pos + 1);
151 1
                $cValuesArr = explode(' ', $cValues);
152 1
                foreach ($cValuesArr as &$cArg) {
153 1
                    $cArg = escapeshellarg($cArg);
154
                }
155 1
                $cValues = implode(' ', $cValuesArr);
156 1
                $cmdOptions[] = '-' . $cName . ' ' . $cValues;
157
            }
158
        }
159 1
        return $cmdOptions;
160
    }
161
162
    /**
163
     * Build command line options for a given version of cwebp.
164
     *
165
     * The "-near_lossless" param is not supported on older versions of cwebp, so skip on those.
166
     *
167
     * @param  string $version  Version of cwebp.
168
     * @return string
169
     */
170 6
    private function createCommandLineOptions($version)
171
    {
172
173 6
        $this->logLn('Creating command line options for version: ' . $version);
174
175
        // we only need two decimal places for version.
176
        // convert to number to make it easier to compare
177 6
        $version = preg_match('#^\d+\.\d+#', $version, $matches);
178 6
        $versionNum = 0;
179 6
        if (isset($matches[0])) {
180 6
            $versionNum = floatval($matches[0]);
181
        } else {
182
            $this->logLn(
183
                'Could not extract version number from the following version string: ' . $version,
184
                'bold'
185
            );
186
        }
187
188
        //$this->logLn('version:' . strval($versionNum));
189
190 6
        $options = $this->options;
191
192 6
        $cmdOptions = [];
193
194
        // Metadata (all, exif, icc, xmp or none (default))
195
        // Comma-separated list of existing metadata to copy from input to output
196 6
        if ($versionNum >= 0.3) {
197 6
            $cmdOptions[] = '-metadata ' . $options['metadata'];
198
        }
199
200
        // preset. Appears first in the list as recommended in the docs
201 6
        if (!is_null($options['preset'])) {
202 6
            if ($options['preset'] != 'none') {
203 1
                $cmdOptions[] = '-preset ' . $options['preset'];
204
            }
205
        }
206
207
        // Size
208 6
        $addedSizeOption = false;
209 6
        if (!is_null($options['size-in-percentage'])) {
210 1
            $sizeSource = filesize($this->source);
211 1
            if ($sizeSource !== false) {
212 1
                $targetSize = floor($sizeSource * $options['size-in-percentage'] / 100);
213 1
                $cmdOptions[] = '-size ' . $targetSize;
214 1
                $addedSizeOption = true;
215
            }
216
        }
217
218
        // quality
219 6
        if (!$addedSizeOption) {
220 5
            $cmdOptions[] = '-q ' . $this->getCalculatedQuality();
221
        }
222
223
        // alpha-quality
224 6
        if ($this->options['alpha-quality'] !== 100) {
225 6
            $cmdOptions[] = '-alpha_q ' . escapeshellarg($this->options['alpha-quality']);
226
        }
227
228
        // Losless PNG conversion
229 6
        if ($options['encoding'] == 'lossless') {
230
            // No need to add -lossless when near-lossless is used (on version >= 0.5)
231 4
            if (($options['near-lossless'] === 100) || ($versionNum < 0.5)) {
232 1
                $cmdOptions[] = '-lossless';
233
            }
234
        }
235
236
        // Near-lossles
237 6
        if ($options['near-lossless'] !== 100) {
238 5
            if ($versionNum < 0.5) {
239
                $this->logLn(
240
                    'The near-lossless option is not supported on this (rather old) version of cwebp' .
241
                        '- skipping it.',
242
                    'italic'
243
                );
244
            } else {
245
                // We only let near_lossless have effect when encoding is set to "lossless"
246
                // otherwise encoding=auto would not work as expected
247
248 5
                if ($options['encoding'] == 'lossless') {
249 3
                    $cmdOptions[] ='-near_lossless ' . $options['near-lossless'];
250
                } else {
251 4
                    $this->logLn(
252 4
                        'The near-lossless option ignored for lossy'
253
                    );
254
                }
255
            }
256
        }
257
258 6
        if ($options['auto-filter'] === true) {
259 1
            $cmdOptions[] = '-af';
260
        }
261
262
        // Built-in method option
263 6
        $cmdOptions[] = '-m ' . strval($options['method']);
264
265
        // Built-in low memory option
266 6
        if ($options['low-memory']) {
267 1
            $cmdOptions[] = '-low_memory';
268
        }
269
270
        // command-line-options
271 6
        if ($options['command-line-options']) {
272
            /*
273
            In some years, we can use the splat instead (requires PHP 5.6)
274
            array_push(
275
                $cmdOptions,
276
                ...self::escapeShellArgOnCommandLineOptions($options['command-line-options'])
277
            );
278
            */
279 1
            foreach (self::escapeShellArgOnCommandLineOptions($options['command-line-options']) as $cmdLineOption) {
280 1
                array_push($cmdOptions, $cmdLineOption);
281
            }
282
283
        }
284
285
        // Source file
286 6
        $cmdOptions[] = escapeshellarg($this->source);
287
288
        // Output
289 6
        $cmdOptions[] = '-o ' . escapeshellarg($this->destination);
290
291
        // Redirect stderr to same place as stdout
292
        // https://www.brianstorti.com/understanding-shell-script-idiom-redirect/
293 6
        $cmdOptions[] = '2>&1';
294
295 6
        $commandOptions = implode(' ', $cmdOptions);
296
        //$this->logLn('command line options:' . $commandOptions);
297
298 6
        return $commandOptions;
299
    }
300
301
    /**
302
     *  Get path for supplied binary for current OS - and validate hash.
303
     *
304
     *  @return  array  Array of supplied binaries (which actually exists, and where hash validates)
305
     */
306 2
    private function getSuppliedBinaryPathForOS()
307
    {
308 2
        $this->log('Checking if we have a supplied binary for OS: ' . PHP_OS . '... ');
309
310
        // Try supplied binary (if available for OS, and hash is correct)
311 2
        $options = $this->options;
312 2
        if (!isset(self::$suppliedBinariesInfo[PHP_OS])) {
313
            $this->logLn('No we dont - not for that OS');
314
            return [];
315
        }
316 2
        $this->logLn('We do.');
317
318 2
        $result = [];
319 2
        $files = self::$suppliedBinariesInfo[PHP_OS];
320 2
        if (count($files) > 0) {
321 2
            $this->logLn('We in fact have ' . count($files));
322
        }
323
324 2
        foreach ($files as $i => list($file, $hash)) {
325
            //$file = $info[0];
326
            //$hash = $info[1];
327
328 2
            $binaryFile = __DIR__ . '/' . $options['rel-path-to-precompiled-binaries'] . '/' . $file;
329
330
            // Replace "/./" with "/" in path (we could alternatively use realpath)
331
            //$binaryFile = preg_replace('#\/\.\/#', '/', $binaryFile);
332
            // The file should exist, but may have been removed manually.
333
            /*
334
            if (!file_exists($binaryFile)) {
335
                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
336
                return false;
337
            }*/
338
339 2
            $realPathResult = realpath($binaryFile);
340 2
            if ($realPathResult === false) {
341
                $this->logLn('Supplied binary not found! It ought to be here:' . $binaryFile, 'italic');
342
                continue;
343
            }
344 2
            $binaryFile = $realPathResult;
345
346
            // File exists, now generate its hash
347
            // hash_file() is normally available, but it is not always
348
            // - https://stackoverflow.com/questions/17382712/php-5-3-20-undefined-function-hash
349
            // If available, validate that hash is correct.
350
351 2
            if (function_exists('hash_file')) {
352 2
                $binaryHash = hash_file('sha256', $binaryFile);
353
354 2
                if ($binaryHash != $hash) {
355
                    $this->logLn(
356
                        'Binary checksum of supplied binary is invalid! ' .
357
                        'Did you transfer with FTP, but not in binary mode? ' .
358
                        'File:' . $binaryFile . '. ' .
359
                        'Expected checksum: ' . $hash . '. ' .
360
                        'Actual checksum:' . $binaryHash . '.',
361
                        'bold'
362
                    );
363
                    continue;
364
                }
365
            }
366 2
            $result[] = $binaryFile;
367
        }
368
369 2
        return $result;
370
    }
371
372 2
    private function discoverBinaries()
373
    {
374 2
        $this->logLn('Locating cwebp binaries');
375
376 2
        if (defined('WEBPCONVERT_CWEBP_PATH')) {
377
            $this->logLn('WEBPCONVERT_CWEBP_PATH was defined, so using that path and ignoring any other');
378
            //$this->logLn('Value: "' . getenv('WEBPCONVERT_CWEBP_PATH') . '"');
379
            return [constant('WEBPCONVERT_CWEBP_PATH')];
380
        }
381 2
        if (!empty(getenv('WEBPCONVERT_CWEBP_PATH'))) {
382
            $this->logLn(
383
                'WEBPCONVERT_CWEBP_PATH environment variable was set, so using that path and ignoring any other'
384
            );
385
            //$this->logLn('Value: "' . getenv('WEBPCONVERT_CWEBP_PATH') . '"');
386
            return [getenv('WEBPCONVERT_CWEBP_PATH')];
387
        }
388
389 2
        $binaries = [];
390 2
        if ($this->options['try-common-system-paths']) {
391 1
            foreach (self::$cwebpDefaultPaths as $binary) {
392 1
                if (@file_exists($binary)) {
393 1
                    $binaries[] = $binary;
394
                }
395
            }
396 1
            if (count($binaries) == 0) {
397 1
                $this->logLn('No cwebp binaries where located in common system locations');
398
            } else {
399
                $this->logLn(strval(count($binaries)) . ' cwebp binaries found in common system locations');
400
            }
401
        }
402
        // TODO: exec('whereis cwebp');
403 2
        if ($this->options['try-supplied-binary-for-os']) {
404 2
            $suppliedBinaries = $this->getSuppliedBinaryPathForOS();
405 2
            foreach ($suppliedBinaries as $suppliedBinary) {
406 2
                $binaries[] = $suppliedBinary;
407
            }
408
        } else {
409
            $this->logLn('Configured not to try the cwebp binary that comes bundled with webp-convert');
410
        }
411
412 2
        if (count($binaries) == 0) {
413
            $this->logLn('No cwebp binaries to try!');
414
        }
415 2
        $this->logLn('A total of ' . strval(count($binaries)) . ' cwebp binaries where found');
416 2
        return $binaries;
417
    }
418
419
    /**
420
     *
421
     * @return  string|int  Version string (ie "1.0.2") OR return code, in case of failure
422
     */
423 2
    private function detectVersion($binary)
424
    {
425
        //$this->logLn('Examining binary: ' . $binary);
426 2
        $command = $binary . ' -version';
427 2
        $this->log('Executing: ' . $command);
428 2
        exec($command, $output, $returnCode);
429
430 2
        if ($returnCode == 0) {
431
            //$this->logLn('Success');
432 2
            if (isset($output[0])) {
433 2
                $this->logLn('. Result: version: ' . $output[0]);
434 2
                return $output[0];
435
            }
436
        } else {
437
            $this->logExecOutput($output);
438
            $this->logLn('');
439
            if ($returnCode == 127) {
440
                $this->logLn('Exec failed (the cwebp binary was not found at path: ' . $binary. ')');
441
            } else {
442
                $this->logLn(
443
                    'Exec failed (return code: ' . $returnCode . ')'
444
                );
445
                if ($returnCode == 126) {
446
                    $this->logLn(
447
                        'PS: Return code 126 means "Permission denied". The user that the command was run with does ' .
448
                            'not have permission to execute that binary.'
449
                    );
450
                    // TODO: further info: shell_exec('whoami')
451
                }
452
            }
453
            return $returnCode;
454
        }
455
    }
456
457
    /**
458
     *  Check versions for binaries, and return array (indexed by the binary, value being the version of the binary).
459
     *
460
     *  @return  array
461
     */
462 2
    private function detectVersions($binaries)
463
    {
464 2
        $binariesWithVersions = [];
465 2
        $binariesWithFailCodes = [];
466
467 2
        $this->logLn(
468 2
            'Detecting versions of the cwebp binaries found (and verifying that they can be executed in the process)'
469
        );
470 2
        foreach ($binaries as $binary) {
471 2
            $versionStringOrFailCode = $this->detectVersion($binary);
472
        //    $this->logLn($binary . ': ' . $versionString);
473 2
            if (gettype($versionStringOrFailCode) == 'string') {
474 2
                $binariesWithVersions[$binary] = $versionStringOrFailCode;
475
            } else {
476 2
                $binariesWithFailCodes[$binary] = $versionStringOrFailCode;
477
            }
478
        }
479 2
        return ['detected' => $binariesWithVersions, 'failed' => $binariesWithFailCodes];
480
    }
481
482
    /**
483
     * @return  boolean  success or not.
484
     */
485 2
    private function tryBinary($binary, $version, $useNice)
486
    {
487
488
        //$this->logLn('Trying binary: ' . $binary);
489 2
        $commandOptions = $this->createCommandLineOptions($version);
490
491 2
        $returnCode = $this->executeBinary($binary, $commandOptions, $useNice);
492 2
        if ($returnCode == 0) {
493
            // It has happened that even with return code 0, there was no file at destination.
494 2
            if (!file_exists($this->destination)) {
495
                $this->logLn('executing cweb returned success code - but no file was found at destination!');
496
                return false;
497
            } else {
498 2
                $this->logLn('Success');
499 2
                return true;
500
            }
501
        } else {
502
            $this->logLn(
503
                'Exec failed (return code: ' . $returnCode . ')'
504
            );
505
            return false;
506
        }
507
    }
508
509 2
    protected function doActualConvert()
510
    {
511 2
        $binaries = $this->discoverBinaries();
512
513 2
        if (count($binaries) == 0) {
514
            throw new SystemRequirementsNotMetException(
515
                'No cwebp binaries located. Check the conversion log for details.'
516
            );
517
        }
518
519 2
        $versions = $this->detectVersions($binaries);
520 2
        if (count($versions['detected']) == 0) {
521
            //$this->logLn('None of the cwebp files located can be executed.');
522
            if (count($binaries) == 1) {
523
                $errorMsg = 'The cwebp file found cannot be can be executed.';
524
            } else {
525
                $errorMsg = 'None of the cwebp files located can be executed.';
526
            }
527
            $uniqueFailCodes = array_unique(array_values($versions['failed']));
528
            if (count($uniqueFailCodes) == 1) {
529
                $errorMsg .= ' ' . (count($binaries) == 1 ? 'It' : 'All') .
530
                    ' failed with return code ' . $uniqueFailCodes[0];
531
                if ($uniqueFailCodes[0] == 126) {
532
                    $errorMsg .= ' (permission denied)';
533
                }
534
            } else {
535
                $errorMsg .= ' Failure codes : ' . implode(', ', $uniqueFailCodes);
536
            }
537
538
            throw new SystemRequirementsNotMetException($errorMsg);
539
        }
540
541 2
        $binaryVersions = $versions['detected'];
542
543 2
        if (count($binaries) > 1) {
544 2
            $this->logLn(
545 2
                'Trying executing the cwebs found until success. Starting with the ones with highest version number.'
546
            );
547
        }
548
        //$this->logLn('binary versions: ' . print_r($binaryVersions, true));
549
550
        // Sort binaries so those with highest numbers comes first
551 2
        arsort($binaryVersions);
552
553
        //$this->logLn('binary versions (ordered by version): ' . print_r($binaryVersions, true));
554
555 2
        $useNice = (($this->options['use-nice']) && self::hasNiceSupport());
556
557 2
        $success = false;
558 2
        foreach ($binaryVersions as $binary => $version) {
559 2
            if ($this->tryBinary($binary, $version, $useNice)) {
560 2
                $success = true;
561 2
                break;
562
            }
563
        }
564
565
        // cwebp sets file permissions to 664 but instead ..
566
        // .. $destination's parent folder's permissions should be used (except executable bits)
567
        // (or perhaps the current umask instead? https://www.php.net/umask)
568
569 2
        if ($success) {
570 2
            $destinationParent = dirname($this->destination);
571 2
            $fileStatistics = stat($destinationParent);
572 2
            if ($fileStatistics !== false) {
573
                // Apply same permissions as parent folder but strip off the executable bits
574 2
                $permissions = $fileStatistics['mode'] & 0000666;
575 2
                chmod($this->destination, $permissions);
576
            }
577
        } else {
578
            throw new SystemRequirementsNotMetException('Failed converting. Check the conversion log for details.');
579
        }
580 2
    }
581
}
582