Passed
Push — master ( e20300...385468 )
by Bjørn
02:06
created

AbstractConverter   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 382
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 132
dl 0
loc 382
rs 9.36
c 0
b 0
f 0
wmc 38

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A convert() 0 4 1
A checkOperationality() 0 2 1
A removeExistingDestinationIfExists() 0 8 3
A runActualConvert() 0 41 5
B doConvert() 0 70 10
A checkInput() 0 13 4
A createWritableDestinationFolder() 0 13 3
A checkConvertability() 0 2 1
A getMimeTypeOfSource() 0 6 2
A checkFileSystem() 0 20 2
A createInstance() 0 3 1
A getConverterDisplayName() 0 4 1
A errorHandler() 0 49 3
1
<?php
2
3
// TODO:
4
// Read this: https://sourcemaking.com/design_patterns/strategy
5
6
namespace WebPConvert\Convert\BaseConverters;
7
8
use WebPConvert\Convert\Exceptions\ConversionFailedException;
9
use WebPConvert\Convert\Exceptions\ConversionFailed\ConversionDeclinedException;
10
use WebPConvert\Convert\Exceptions\ConversionFailed\UnhandledException;
11
use WebPConvert\Convert\Exceptions\ConversionFailed\FileSystemProblems\CreateDestinationFileException;
12
use WebPConvert\Convert\Exceptions\ConversionFailed\FileSystemProblems\CreateDestinationFolderException;
13
use WebPConvert\Convert\Exceptions\ConversionFailed\InvalidInput\ConverterNotFoundException;
14
use WebPConvert\Convert\Exceptions\ConversionFailed\InvalidInput\InvalidImageTypeException;
15
use WebPConvert\Convert\Exceptions\ConversionFailed\InvalidInput\TargetNotFoundException;
16
use WebPConvert\Convert\Exceptions\ConversionFailed\ConverterNotOperational\SystemRequirementsNotMetException;
17
use WebPConvert\Convert\BaseConverters\BaseTraits\AutoQualityTrait;
18
use WebPConvert\Convert\BaseConverters\BaseTraits\LoggerTrait;
19
use WebPConvert\Convert\BaseConverters\BaseTraits\OptionsTrait;
20
use WebPConvert\Loggers\BaseLogger;
21
22
use ImageMimeTypeGuesser\ImageMimeTypeGuesser;
23
24
abstract class AbstractConverter
25
{
26
    use AutoQualityTrait;
27
    use LoggerTrait;
28
    use OptionsTrait;
29
30
    /**
31
     * The actual conversion must be done by a concrete class.
32
     *
33
     * At the stage this method is called, the abstract converter has taken preparational steps.
34
     * - It has created the destination folder (if neccesary)
35
     * - It has checked the input (valid mime type)
36
     * - It has set up an error handler, mostly in order to catch and log warnings during the doConvert fase
37
     *
38
     * Note: This method is not meant to be called from the outside. Use the *convert* method for that.
39
     *
40
     */
41
    abstract protected function doActualConvert();
42
43
    /**
44
     *  Set to false for converters that should hand the lossless option over (stack and wpc)
45
     */
46
    protected $processLosslessAuto = true;
47
48
    /**
49
     *  Set this on all converters (unfortunately, properties cannot be declared abstract)
50
     */
51
    protected $supportsLossless = false;
52
53
54
    /** @var string  The filename of the image to convert (complete path) */
55
    public $source;
56
57
    /** @var string  Where to save the webp (complete path) */
58
    public $destination;
59
60
    public $beginTime;
61
    public $sourceMimeType;
62
    public static $allowedMimeTypes = ['image/jpeg', 'image/png'];
63
64
    /**
65
     * Check basis operationality
66
     *
67
     * Converters may override this method for the purpose of performing basic operationaly checks. It is for
68
     * running general operation checks for a conversion method.
69
     * If some requirement is not met, it should throw a ConverterNotOperationalException (or subtype)
70
     *
71
     * The method is called internally right before calling doActualConvert() method.
72
     * - It SHOULD take options into account when relevant. For example, a missing api key for a
73
     *   cloud converter should be detected here
74
     * - It should NOT take the actual filename into consideration, as the purpose is *general*
75
     *   For that pupose, converters should override checkConvertability
76
     *   Also note that doConvert method is allowed to throw ConverterNotOperationalException too.
77
     *
78
     * @return  void
79
     */
80
    protected function checkOperationality()
81
    {
82
    }
83
84
    /**
85
     * Converters may override this for the purpose of performing checks on the concrete file.
86
     *
87
     * This can for example be used for rejecting big uploads in cloud converters or rejecting unsupported
88
     * image types.
89
     *
90
     * @return  void
91
     */
92
    protected function checkConvertability()
93
    {
94
    }
95
96
    public function __construct($source, $destination, $options = [], $logger = null)
97
    {
98
        $this->source = $source;
99
        $this->destination = $destination;
100
101
        $this->setLogger($logger);
102
        $this->setProvidedOptions($options);
103
    }
104
105
    /**
106
     *  Default display name is simply the class name (short).
107
     *  Converters can override this.
108
     */
109
    protected static function getConverterDisplayName()
110
    {
111
        // https://stackoverflow.com/questions/19901850/how-do-i-get-an-objects-unqualified-short-class-name/25308464
112
        return substr(strrchr('\\' . static::class, '\\'), 1);
113
    }
114
115
    public static function createInstance($source, $destination, $options = [], $logger = null)
116
    {
117
        return new static($source, $destination, $options, $logger);
118
    }
119
120
    /**
121
     *
122
     *
123
     */
124
    public function errorHandler($errno, $errstr, $errfile, $errline)
125
    {
126
        /*
127
        We do NOT do the following (even though it is generally recommended):
128
129
        if (!(error_reporting() & $errno)) {
130
            // This error code is not included in error_reporting, so let it fall
131
            // through to the standard PHP error handler
132
            return false;
133
        }
134
135
        - Because we want to log all warnings and errors (also the ones that was suppressed with @)
136
        https://secure.php.net/manual/en/language.operators.errorcontrol.php
137
        */
138
139
        $errorTypes = [
140
            E_WARNING =>             "Warning",
141
            E_NOTICE =>              "Notice",
142
            E_USER_ERROR =>          "User Error",
143
            E_USER_WARNING =>        "User Warning",
144
            E_USER_NOTICE =>         "User Notice",
145
            E_STRICT =>              "Strict Notice",
146
            E_DEPRECATED =>          "Deprecated",
147
            E_USER_DEPRECATED =>     "User Deprecated",
148
149
            /*
150
            The following can never be catched by a custom error handler:
151
            E_PARSE, E_ERROR, E_CORE_ERROR, E_CORE_WARNING, E_COMPILE_ERROR, E_COMPILE_WARNING
152
153
            We do do not trigger the following, but actually, triggering warnings and notices
154
            is perhaps a good alternative to calling logLn
155
            E_USER_ERROR, E_USER_WARNING, E_USER_NOTICE
156
            */
157
        ];
158
159
        if (isset($errorTypes[$errno])) {
160
            $errType = $errorTypes[$errno];
161
        } else {
162
            $errType = "Unknown error ($errno)";
163
        }
164
165
        $msg = $errType . ': ' . $errstr . ' in ' . $errfile . ', line ' . $errline . ', PHP ' . PHP_VERSION .
166
            ' (' . PHP_OS . ')';
167
        $this->logLn($msg);
168
169
        if ($errno == E_USER_ERROR) {
170
            // trigger error.
171
            // unfortunately, we can only catch user errors
172
            throw new ConversionFailedException('Uncaught error in converter', $msg);
173
        }
174
175
        // We do not return false, because we want to keep this little secret.
176
        //return false;   // let PHP handle the error from here
177
    }
178
179
    //$instance->logLn($instance->getConverterDisplayName() . ' converter ignited');
180
    //$instance->logLn(self::getConverterDisplayName() . ' converter ignited');
181
182
    public function doConvert()
183
    {
184
        $this->beginTime = microtime(true);
185
186
        //set_error_handler(array($this, "warningHandler"), E_WARNING);
187
        set_error_handler(array($this, "errorHandler"));
188
189
        try {
190
            // Prepare options
191
            //$this->prepareOptions();
192
193
            $this->checkOptions();
194
195
            // Prepare destination folder
196
            $this->createWritableDestinationFolder();
197
            $this->removeExistingDestinationIfExists();
198
199
            if (!isset($this->options['_skip_input_check'])) {
200
                // Run basic input validations (if source exists and if file extension is valid)
201
                $this->checkInput();
202
203
                // Check that a file can be written to destination
204
                $this->checkFileSystem();
205
            }
206
207
            $this->checkOperationality();
208
            $this->checkConvertability();
209
            $this->runActualConvert();
210
        } catch (ConversionFailedException $e) {
211
            restore_error_handler();
212
            throw $e;
213
        } catch (\Exception $e) {
214
            restore_error_handler();
215
            throw new UnhandledException('Conversion failed due to uncaught exception', 0, $e);
216
        } catch (\Error $e) {
217
            restore_error_handler();
218
            // https://stackoverflow.com/questions/7116995/is-it-possible-in-php-to-prevent-fatal-error-call-to-undefined-function
219
            //throw new UnhandledException('Conversion failed due to uncaught error', 0, $e);
220
            throw $e;
221
        }
222
        restore_error_handler();
223
224
        $source = $this->source;
225
        $destination = $this->destination;
226
227
        if (!@file_exists($destination)) {
228
            throw new ConversionFailedException('Destination file is not there: ' . $destination);
229
        } elseif (@filesize($destination) === 0) {
230
            unlink($destination);
231
            throw new ConversionFailedException('Destination file was completely empty');
232
        } else {
233
            if (!isset($this->options['_suppress_success_message'])) {
234
                $this->ln();
235
                $msg = 'Converted image in ' .
236
                    round((microtime(true) - $this->beginTime) * 1000) . ' ms';
237
238
                $sourceSize = @filesize($source);
239
                if ($sourceSize !== false) {
240
                    $msg .= ', reducing file size with ' .
241
                        round((filesize($source) - filesize($destination))/filesize($source) * 100) . '% ';
242
243
                    if ($sourceSize < 10000) {
244
                        $msg .= '(went from ' . round(filesize($source)) . ' bytes to ';
245
                        $msg .= round(filesize($destination)) . ' bytes)';
246
                    } else {
247
                        $msg .= '(went from ' . round(filesize($source)/1024) . ' kb to ';
248
                        $msg .= round(filesize($destination)/1024) . ' kb)';
249
                    }
250
                }
251
                $this->logLn($msg);
252
            }
253
        }
254
    }
255
256
257
    private function runActualConvert()
258
    {
259
        if ($this->processLosslessAuto && ($this->options['lossless'] === 'auto') && $this->supportsLossless) {
260
            $destination = $this->destination;
261
            $destinationLossless =  $this->destination . '.lossless.webp';
262
            $destinationLossy =  $this->destination . '.lossy.webp';
263
264
            $this->logLn(
265
                'Lossless is set to auto. Converting to both lossless and lossy and selecting the smallest file'
266
            );
267
268
269
            $this->ln();
270
            $this->logLn('Converting to lossy');
271
            $this->destination = $destinationLossy;
272
            $this->options['lossless'] = false;
273
            $this->doActualConvert();
274
            $this->logLn('Reduction: ' .
275
                round((filesize($this->source) - filesize($this->destination))/filesize($this->source) * 100) . '% ');
276
277
            $this->ln();
278
            $this->logLn('Converting to lossless');
279
            $this->destination = $destinationLossless;
280
            $this->options['lossless'] = true;
281
            $this->doActualConvert();
282
            $this->logLn('Reduction: ' .
283
                round((filesize($this->source) - filesize($this->destination))/filesize($this->source) * 100) . '% ');
284
285
            $this->ln();
286
            if (filesize($destinationLossless) > filesize($destinationLossy)) {
287
                $this->logLn('Picking lossy');
288
                unlink($destinationLossless);
289
                rename($destinationLossy, $destination);
290
            } else {
291
                $this->logLn('Picking lossless');
292
                unlink($destinationLossy);
293
                rename($destinationLossless, $destination);
294
            }
295
            $this->destination = $destination;
296
        } else {
297
            $this->doActualConvert();
298
        }
299
    }
300
301
    /**
302
     * Convert an image to webp.
303
     *
304
     * @param   string  $source              path to source file
305
     * @param   string  $destination         path to destination
306
     * @param   array   $options (optional)  options for conversion
307
     * @param   \WebPConvert\Loggers\BaseLogger $logger (optional)
308
     * @return  void
309
     */
310
    public static function convert($source, $destination, $options = [], $logger = null)
311
    {
312
        $instance = self::createInstance($source, $destination, $options, $logger);
313
        $instance->doConvert();
314
        //echo $instance->id;
315
    }
316
317
    /**
318
     * Get mime type for image (best guess).
319
     *
320
     * It falls back to using file extension. If that fails too, false is returned
321
     *
322
     * PS: Is it a security risk to fall back on file extension?
323
     * - By setting file extension to "jpg", one can lure our library into trying to convert a file, which isn't a jpg.
324
     * hmm, seems very unlikely, though not unthinkable that one of the converters could be exploited
325
     *
326
     * @return  string|false
327
     */
328
    public function getMimeTypeOfSource()
329
    {
330
        if (!isset($this->sourceMimeType)) {
331
            $this->sourceMimeType = ImageMimeTypeGuesser::lenientGuess($this->source);
332
        }
333
        return $this->sourceMimeType;
334
    }
335
336
    /**
337
     *  Note: As the input validations are only run one time in a stack,
338
     *  this method is not overridable
339
     */
340
    private function checkInput()
341
    {
342
        // Check if source exists
343
        if (!@file_exists($this->source)) {
344
            throw new TargetNotFoundException('File or directory not found: ' . $this->source);
345
        }
346
347
        // Check if the provided file's mime type is valid
348
        $fileMimeType = $this->getMimeTypeOfSource();
349
        if ($fileMimeType === false) {
350
            throw new InvalidImageTypeException('Image type could not be detected');
351
        } elseif (!in_array($fileMimeType, self::$allowedMimeTypes)) {
352
            throw new InvalidImageTypeException('Unsupported mime type: ' . $fileMimeType);
353
        }
354
    }
355
356
    private function checkFileSystem()
357
    {
358
        // TODO:
359
        // Instead of creating dummy file,
360
        // perhaps something like this ?
361
        // if (@is_writable($dirName) && @is_executable($dirName) || self::isWindows() )
362
        // Or actually, probably best with a mix.
363
        // First we test is_writable and is_executable. If that fails and we are on windows, we can do the dummy
364
        // function isWindows(){
365
        // return (boolean) preg_match('/^win/i', PHP_OS);
366
        //}
367
368
        // Try to create a dummy file here, with that name, just to see if it is possible (we delete it again)
369
        file_put_contents($this->destination, '');
370
        if (file_put_contents($this->destination, '') === false) {
371
            throw new CreateDestinationFileException(
372
                'Cannot create file: ' . basename($this->destination) . ' in dir:' . dirname($this->destination)
373
            );
374
        }
375
        unlink($this->destination);
376
    }
377
378
    private function removeExistingDestinationIfExists()
379
    {
380
        if (file_exists($this->destination)) {
381
            // A file already exists in this folder...
382
            // We delete it, to make way for a new webp
383
            if (!unlink($this->destination)) {
384
                throw new CreateDestinationFileException(
385
                    'Existing file cannot be removed: ' . basename($this->destination)
386
                );
387
            }
388
        }
389
    }
390
391
    // Creates folder in provided path & sets correct permissions
392
    // also deletes the file at filePath (if it already exists)
393
    private function createWritableDestinationFolder()
394
    {
395
        $filePath = $this->destination;
396
397
        $folder = dirname($filePath);
398
        if (!file_exists($folder)) {
399
            $this->logLn('Destination folder does not exist. Creating folder: ' . $folder);
400
            // TODO: what if this is outside open basedir?
401
            // see http://php.net/manual/en/ini.core.php#ini.open-basedir
402
403
            // Trying to create the given folder (recursively)
404
            if (!mkdir($folder, 0777, true)) {
405
                throw new CreateDestinationFolderException('Failed creating folder: ' . $folder);
406
            }
407
        }
408
    }
409
}
410