Passed
Push — master ( 74ce7c...3af96c )
by Bjørn
03:41
created

AbstractConverter::supportsLossless()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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