Completed
Push — master ( 5f5fca...2fa1e0 )
by Bjørn
12:28 queued 02:24
created

AbstractConverter::errorHandler()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 49
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 3.0146

Importance

Changes 0
Metric Value
cc 3
eloc 18
nc 4
nop 4
dl 0
loc 49
ccs 15
cts 17
cp 0.8824
crap 3.0146
rs 9.6666
c 0
b 0
f 0

1 Method

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