Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.64% covered (danger)
30.64%
91 / 297
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
BitmapHandler
30.64% covered (danger)
30.64%
91 / 297
6.67% covered (danger)
6.67%
1 / 15
3302.68
0.00% covered (danger)
0.00%
0 / 1
 getScalerType
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
16.11
 hasGDSupport
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeParamString
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseParamString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 validateParam
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 normaliseParams
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 imageMagickSubsampling
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 transformImageMagick
66.67% covered (warning)
66.67%
52 / 78
0.00% covered (danger)
0.00%
0 / 1
34.81
 transformImageMagickExt
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
462
 transformCustom
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 transformGd
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
156
 imageJpegWrapper
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 canRotate
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 autoRotateEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 rotate
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
1<?php
2/**
3 * Generic handler for bitmap images.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup Media
22 */
23
24use MediaWiki\FileRepo\File\File;
25use MediaWiki\MainConfigNames;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Shell\Shell;
28
29/**
30 * Generic handler for bitmap images
31 *
32 * @stable to extend
33 * @ingroup Media
34 */
35class BitmapHandler extends TransformationalImageHandler {
36
37    /**
38     * Returns which scaler type should be used. Creates parent directories
39     * for $dstPath and returns 'client' on error
40     * @stable to override
41     *
42     * @param string|null $dstPath
43     * @param bool $checkDstPath
44     * @return string|callable One of client, im, custom, gd, imext or an array( object, method )
45     */
46    protected function getScalerType( $dstPath, $checkDstPath = true ) {
47        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
48        $useImageResize = $mainConfig->get( MainConfigNames::UseImageResize );
49        $useImageMagick = $mainConfig->get( MainConfigNames::UseImageMagick );
50        $customConvertCommand = $mainConfig->get( MainConfigNames::CustomConvertCommand );
51        if ( !$dstPath && $checkDstPath ) {
52            # No output path available, client side scaling only
53            $scaler = 'client';
54        } elseif ( !$useImageResize ) {
55            $scaler = 'client';
56        } elseif ( $useImageMagick ) {
57            $scaler = 'im';
58        } elseif ( $customConvertCommand ) {
59            $scaler = 'custom';
60        } elseif ( $this->hasGDSupport() && function_exists( 'imagecreatetruecolor' ) ) {
61            $scaler = 'gd';
62        } elseif ( class_exists( 'Imagick' ) ) {
63            $scaler = 'imext';
64        } else {
65            $scaler = 'client';
66        }
67
68        return $scaler;
69    }
70
71    /**
72     * Whether the php-gd extension supports this type of file.
73     *
74     * @stable to override
75     * @return bool
76     */
77    protected function hasGDSupport() {
78        return true;
79    }
80
81    /**
82     * @inheritDoc
83     * @stable to override
84     */
85    public function makeParamString( $params ) {
86        $res = parent::makeParamString( $params );
87        if ( isset( $params['interlace'] ) && $params['interlace'] ) {
88            return "interlaced-$res";
89        }
90        return $res;
91    }
92
93    /**
94     * @inheritDoc
95     * @stable to override
96     */
97    public function parseParamString( $str ) {
98        $remainder = preg_replace( '/^interlaced-/', '', $str );
99        $params = parent::parseParamString( $remainder );
100        if ( $params === false ) {
101            return false;
102        }
103        $params['interlace'] = $str !== $remainder;
104        return $params;
105    }
106
107    /**
108     * @inheritDoc
109     * @stable to override
110     */
111    public function validateParam( $name, $value ) {
112        if ( $name === 'interlace' ) {
113            return $value === false || $value === true;
114        }
115        return parent::validateParam( $name, $value );
116    }
117
118    /**
119     * @stable to override
120     * @param File $image
121     * @param array &$params
122     * @return bool
123     */
124    public function normaliseParams( $image, &$params ) {
125        $maxInterlacingAreas = MediaWikiServices::getInstance()->getMainConfig()
126            ->get( MainConfigNames::MaxInterlacingAreas );
127        if ( !parent::normaliseParams( $image, $params ) ) {
128            return false;
129        }
130        $mimeType = $image->getMimeType();
131        $interlace = isset( $params['interlace'] ) && $params['interlace']
132            && isset( $maxInterlacingAreas[$mimeType] )
133            && $this->getImageArea( $image ) <= $maxInterlacingAreas[$mimeType];
134        $params['interlace'] = $interlace;
135        return true;
136    }
137
138    /**
139     * Get ImageMagick subsampling factors for the target JPEG pixel format.
140     *
141     * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420'
142     * @return string[] List of sampling factors
143     */
144    protected function imageMagickSubsampling( $pixelFormat ) {
145        switch ( $pixelFormat ) {
146            case 'yuv444':
147                return [ '1x1', '1x1', '1x1' ];
148            case 'yuv422':
149                return [ '2x1', '1x1', '1x1' ];
150            case 'yuv420':
151                return [ '2x2', '1x1', '1x1' ];
152            default:
153                throw new UnexpectedValueException( 'Invalid pixel format for JPEG output' );
154        }
155    }
156
157    /**
158     * Transform an image using ImageMagick
159     * @stable to override
160     *
161     * @param File $image File associated with this thumbnail
162     * @param array $params Array with scaler params
163     *
164     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
165     */
166    protected function transformImageMagick( $image, $params ) {
167        # use ImageMagick
168        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
169        $sharpenReductionThreshold = $mainConfig->get( MainConfigNames::SharpenReductionThreshold );
170        $sharpenParameter = $mainConfig->get( MainConfigNames::SharpenParameter );
171        $maxAnimatedGifArea = $mainConfig->get( MainConfigNames::MaxAnimatedGifArea );
172        $imageMagickTempDir = $mainConfig->get( MainConfigNames::ImageMagickTempDir );
173        $imageMagickConvertCommand = $mainConfig->get( MainConfigNames::ImageMagickConvertCommand );
174        $jpegPixelFormat = $mainConfig->get( MainConfigNames::JpegPixelFormat );
175        $jpegQuality = $mainConfig->get( MainConfigNames::JpegQuality );
176        $quality = [];
177        $sharpen = [];
178        $scene = false;
179        $animation_pre = [];
180        $animation_post = [];
181        $decoderHint = [];
182        $subsampling = [];
183
184        if ( $params['mimeType'] === 'image/jpeg' ) {
185            $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
186            $quality = [ '-quality', $qualityVal ?: (string)$jpegQuality ]; // 80% by default
187            if ( $params['interlace'] ) {
188                $animation_post = [ '-interlace', 'JPEG' ];
189            }
190            # Sharpening, see T8193
191            if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
192                / ( $params['srcWidth'] + $params['srcHeight'] )
193                < $sharpenReductionThreshold
194            ) {
195                $sharpen = [ '-sharpen', $sharpenParameter ];
196            }
197
198            // JPEG decoder hint to reduce memory, available since IM 6.5.6-2
199            $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ];
200
201            if ( $jpegPixelFormat ) {
202                $factors = $this->imageMagickSubsampling( $jpegPixelFormat );
203                $subsampling = [ '-sampling-factor', implode( ',', $factors ) ];
204            }
205        } elseif ( $params['mimeType'] === 'image/png' ) {
206            $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
207            if ( $params['interlace'] ) {
208                $animation_post = [ '-interlace', 'PNG' ];
209            }
210        } elseif ( $params['mimeType'] === 'image/webp' ) {
211            $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
212        } elseif ( $params['mimeType'] === 'image/gif' ) {
213            if ( $this->getImageArea( $image ) > $maxAnimatedGifArea ) {
214                // Extract initial frame only; we're so big it'll
215                // be a total drag. :P
216                $scene = 0;
217            } elseif ( $this->isAnimatedImage( $image ) ) {
218                // Coalesce is needed to scale animated GIFs properly (T3017).
219                $animation_pre = [ '-coalesce' ];
220
221                // We optimize the output, but -optimize is broken,
222                // use optimizeTransparency instead (T13822). Version >= IM 6.3.5
223                $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ];
224            }
225            if ( $params['interlace'] && !$this->isAnimatedImage( $image ) ) {
226                // Version >= IM 6.3.4
227                // interlacing animated GIFs is a bad idea
228                $animation_post[] = '-interlace';
229                $animation_post[] = 'GIF';
230            }
231        } elseif ( $params['mimeType'] === 'image/x-xcf' ) {
232            // Before merging layers, we need to set the background
233            // to be transparent to preserve alpha, as -layers merge
234            // merges all layers on to a canvas filled with the
235            // background colour. After merging we reset the background
236            // to be white for the default background colour setting
237            // in the PNG image (which is used in old IE)
238            $animation_pre = [
239                '-background', 'transparent',
240                '-layers', 'merge',
241                '-background', 'white',
242            ];
243        }
244
245        // Use one thread only, to avoid deadlock bugs on OOM
246        $env = [ 'OMP_NUM_THREADS' => 1 ];
247        if ( (string)$imageMagickTempDir !== '' ) {
248            $env['MAGICK_TMPDIR'] = $imageMagickTempDir;
249        }
250
251        $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
252        [ $width, $height ] = $this->extractPreRotationDimensions( $params, $rotation );
253
254        $cmd = Shell::escape( ...array_merge(
255            [ $imageMagickConvertCommand ],
256            $quality,
257            // Specify white background color, will be used for transparent images
258            // in Internet Explorer/Windows instead of default black.
259            [ '-background', 'white' ],
260            $decoderHint,
261            [ $this->escapeMagickInput( $params['srcPath'], $scene ) ],
262            $animation_pre,
263            // For the -thumbnail option a "!" is needed to force exact size,
264            // or ImageMagick may decide your ratio is wrong and slice off
265            // a pixel.
266            [ '-thumbnail', "{$width}x{$height}!" ],
267            // Add the source url as a comment to the thumb, but don't add the flag if there's no comment
268            ( $params['comment'] !== ''
269                ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ]
270                : [] ),
271            // T108616: Avoid exposure of local file path
272            [ '+set', 'Thumb::URI' ],
273            [ '-depth', 8 ],
274            $sharpen,
275            [ '-rotate', "-$rotation" ],
276            $subsampling,
277            $animation_post,
278            [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) );
279
280        wfDebug( __METHOD__ . ": running ImageMagick: $cmd" );
281        $retval = 0;
282        $err = wfShellExecWithStderr( $cmd, $retval, $env );
283
284        if ( $retval !== 0 ) {
285            $this->logErrorForExternalProcess( $retval, $err, $cmd );
286
287            return $this->getMediaTransformError( $params, "$err\nError code: $retval" );
288        }
289
290        return false; # No error
291    }
292
293    /**
294     * Transform an image using the Imagick PHP extension
295     *
296     * @param File $image File associated with this thumbnail
297     * @param array $params Array with scaler params
298     *
299     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
300     */
301    protected function transformImageMagickExt( $image, $params ) {
302        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
303        $sharpenReductionThreshold = $mainConfig->get( MainConfigNames::SharpenReductionThreshold );
304        $sharpenParameter = $mainConfig->get( MainConfigNames::SharpenParameter );
305        $maxAnimatedGifArea = $mainConfig->get( MainConfigNames::MaxAnimatedGifArea );
306        $jpegPixelFormat = $mainConfig->get( MainConfigNames::JpegPixelFormat );
307        $jpegQuality = $mainConfig->get( MainConfigNames::JpegQuality );
308        try {
309            $im = new Imagick();
310            $im->readImage( $params['srcPath'] );
311
312            if ( $params['mimeType'] === 'image/jpeg' ) {
313                // Sharpening, see T8193
314                if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
315                    / ( $params['srcWidth'] + $params['srcHeight'] )
316                    < $sharpenReductionThreshold
317                ) {
318                    // Hack, since $wgSharpenParameter is written specifically for the command line convert
319                    [ $radius, $sigma ] = explode( 'x', $sharpenParameter, 2 );
320                    $im->sharpenImage( (float)$radius, (float)$sigma );
321                }
322                $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
323                $im->setCompressionQuality( $qualityVal ?: $jpegQuality );
324                if ( $params['interlace'] ) {
325                    $im->setInterlaceScheme( Imagick::INTERLACE_JPEG );
326                }
327                if ( $jpegPixelFormat ) {
328                    $factors = $this->imageMagickSubsampling( $jpegPixelFormat );
329                    $im->setSamplingFactors( $factors );
330                }
331            } elseif ( $params['mimeType'] === 'image/png' ) {
332                $im->setCompressionQuality( 95 );
333                if ( $params['interlace'] ) {
334                    $im->setInterlaceScheme( Imagick::INTERLACE_PNG );
335                }
336            } elseif ( $params['mimeType'] === 'image/gif' ) {
337                if ( $this->getImageArea( $image ) > $maxAnimatedGifArea ) {
338                    // Extract initial frame only; we're so big it'll
339                    // be a total drag. :P
340                    $im->setImageScene( 0 );
341                } elseif ( $this->isAnimatedImage( $image ) ) {
342                    // Coalesce is needed to scale animated GIFs properly (T3017).
343                    $im = $im->coalesceImages();
344                }
345                // GIF interlacing is only available since 6.3.4
346                if ( $params['interlace'] ) {
347                    $im->setInterlaceScheme( Imagick::INTERLACE_GIF );
348                }
349            }
350
351            $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
352            [ $width, $height ] = $this->extractPreRotationDimensions( $params, $rotation );
353
354            $im->setImageBackgroundColor( new ImagickPixel( 'white' ) );
355
356            // Call Imagick::thumbnailImage on each frame
357            foreach ( $im as $i => $frame ) {
358                if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) {
359                    return $this->getMediaTransformError( $params, "Error scaling frame $i" );
360                }
361            }
362            $im->setImageDepth( 8 );
363
364            if ( $rotation && !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
365                return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" );
366            }
367
368            if ( $this->isAnimatedImage( $image ) ) {
369                wfDebug( __METHOD__ . ": Writing animated thumbnail" );
370                // This is broken somehow... can't find out how to fix it
371                $result = $im->writeImages( $params['dstPath'], true );
372            } else {
373                $result = $im->writeImage( $params['dstPath'] );
374            }
375            if ( !$result ) {
376                return $this->getMediaTransformError( $params,
377                    "Unable to write thumbnail to {$params['dstPath']}" );
378            }
379        } catch ( ImagickException $e ) {
380            return $this->getMediaTransformError( $params, $e->getMessage() );
381        }
382
383        return false;
384    }
385
386    /**
387     * Transform an image using a custom command
388     *
389     * @param File $image File associated with this thumbnail
390     * @param array $params Array with scaler params
391     *
392     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
393     */
394    protected function transformCustom( $image, $params ) {
395        // Use a custom convert command
396        $customConvertCommand = MediaWikiServices::getInstance()->getMainConfig()
397            ->get( MainConfigNames::CustomConvertCommand );
398
399        // Find all variables in the original command at once,
400        // so that replacement values cannot inject variable placeholders
401        $cmd = strtr( $customConvertCommand, [
402            '%s' => Shell::escape( $params['srcPath'] ),
403            '%d' => Shell::escape( $params['dstPath'] ),
404            '%w' => Shell::escape( $params['physicalWidth'] ),
405            '%h' => Shell::escape( $params['physicalHeight'] ),
406        ] );
407        wfDebug( __METHOD__ . ": Running custom convert command $cmd" );
408        $retval = 0;
409        $err = wfShellExecWithStderr( $cmd, $retval );
410
411        if ( $retval !== 0 ) {
412            $this->logErrorForExternalProcess( $retval, $err, $cmd );
413
414            return $this->getMediaTransformError( $params, $err );
415        }
416
417        return false; # No error
418    }
419
420    /**
421     * Transform an image using the built in GD library
422     *
423     * @param File $image File associated with this thumbnail
424     * @param array $params Array with scaler params
425     *
426     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
427     */
428    protected function transformGd( $image, $params ) {
429        # Use PHP's builtin GD library functions.
430        # First find out what kind of file this is, and select the correct
431        # input routine for this.
432
433        $typemap = [
434            'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ],
435            'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true,
436                [ __CLASS__, 'imageJpegWrapper' ] ],
437            'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ],
438            'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ],
439            'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ],
440        ];
441
442        if ( !isset( $typemap[$params['mimeType']] ) ) {
443            $err = 'Image type not supported';
444            wfDebug( $err );
445            $errMsg = wfMessage( 'thumbnail_image-type' )->text();
446
447            return $this->getMediaTransformError( $params, $errMsg );
448        }
449        [ $loader, $colorStyle, $useQuality, $saveType ] = $typemap[$params['mimeType']];
450
451        if ( !function_exists( $loader ) ) {
452            $err = "Incomplete GD library configuration: missing function $loader";
453            wfDebug( $err );
454            $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text();
455
456            return $this->getMediaTransformError( $params, $errMsg );
457        }
458
459        if ( !file_exists( $params['srcPath'] ) ) {
460            $err = "File seems to be missing: {$params['srcPath']}";
461            wfDebug( $err );
462            $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text();
463
464            return $this->getMediaTransformError( $params, $errMsg );
465        }
466
467        if ( filesize( $params['srcPath'] ) === 0 ) {
468            $err = "Image file size seems to be zero.";
469            wfDebug( $err );
470            $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text();
471
472            return $this->getMediaTransformError( $params, $errMsg );
473        }
474
475        $src_image = $loader( $params['srcPath'] );
476
477        $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ?
478            $this->getRotation( $image ) :
479            0;
480        [ $width, $height ] = $this->extractPreRotationDimensions( $params, $rotation );
481        $dst_image = imagecreatetruecolor( $width, $height );
482
483        // Initialise the destination image to transparent instead of
484        // the default solid black, to support PNG and GIF transparency nicely
485        $background = imagecolorallocate( $dst_image, 0, 0, 0 );
486        imagecolortransparent( $dst_image, $background );
487        imagealphablending( $dst_image, false );
488
489        if ( $colorStyle === 'palette' ) {
490            // Don't resample for paletted GIF images.
491            // It may just uglify them, and completely breaks transparency.
492            imagecopyresized( $dst_image, $src_image,
493                0, 0, 0, 0,
494                $width, $height,
495                imagesx( $src_image ), imagesy( $src_image ) );
496        } else {
497            imagecopyresampled( $dst_image, $src_image,
498                0, 0, 0, 0,
499                $width, $height,
500                imagesx( $src_image ), imagesy( $src_image ) );
501        }
502
503        if ( $rotation % 360 !== 0 && $rotation % 90 === 0 ) {
504            $rot_image = imagerotate( $dst_image, $rotation, 0 );
505            imagedestroy( $dst_image );
506            $dst_image = $rot_image;
507        }
508
509        imagesavealpha( $dst_image, true );
510
511        $funcParams = [ $dst_image, $params['dstPath'] ];
512        if ( $useQuality && isset( $params['quality'] ) ) {
513            $funcParams[] = $params['quality'];
514        }
515        // @phan-suppress-next-line PhanParamTooFewInternalUnpack,PhanParamTooFewUnpack There are at least 2 args
516        $saveType( ...$funcParams );
517
518        imagedestroy( $dst_image );
519        imagedestroy( $src_image );
520
521        return false; # No error
522    }
523
524    /**
525     * Callback for transformGd when transforming jpeg images.
526     *
527     * @phpcs:ignore MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
528     * @param resource|object $dst_image Image resource of the original image
529     * @param string $thumbPath File path to write the thumbnail image to
530     * @param int|null $quality Quality of the thumbnail from 1-100,
531     *    or null to use default quality.
532     */
533    public static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) {
534        $jpegQuality = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::JpegQuality );
535
536        imageinterlace( $dst_image );
537        imagejpeg( $dst_image, $thumbPath, $quality ?? $jpegQuality );
538    }
539
540    /**
541     * Returns whether the current scaler supports rotation (im and gd do)
542     * @stable to override
543     *
544     * @return bool
545     */
546    public function canRotate() {
547        $scaler = $this->getScalerType( null, false );
548        switch ( $scaler ) {
549            case 'im':
550                # ImageMagick supports autorotation
551                return true;
552            case 'imext':
553                # Imagick::rotateImage
554                return true;
555            case 'gd':
556                # GD's imagerotate function is used to rotate images, but not
557                # all precompiled PHP versions have that function
558                return function_exists( 'imagerotate' );
559            default:
560                # Other scalers don't support rotation
561                return false;
562        }
563    }
564
565    /**
566     * @see $wgEnableAutoRotation
567     * @stable to override
568     * @return bool Whether auto rotation is enabled
569     */
570    public function autoRotateEnabled() {
571        $enableAutoRotation = MediaWikiServices::getInstance()->getMainConfig()
572            ->get( MainConfigNames::EnableAutoRotation );
573
574        if ( $enableAutoRotation === null ) {
575            // Only enable auto-rotation when we actually can
576            return $this->canRotate();
577        }
578
579        return $enableAutoRotation;
580    }
581
582    /**
583     * @stable to override
584     * @param File $file
585     * @param array $params Rotate parameters.
586     *   'rotation' clockwise rotation in degrees, allowed are multiples of 90
587     * @since 1.21
588     * @return MediaTransformError|false
589     */
590    public function rotate( $file, $params ) {
591        $imageMagickConvertCommand = MediaWikiServices::getInstance()
592            ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
593
594        $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
595        $scene = false;
596
597        $scaler = $this->getScalerType( null, false );
598        switch ( $scaler ) {
599            case 'im':
600                $cmd = Shell::escape( $imageMagickConvertCommand ) . " " .
601                    Shell::escape( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
602                    " -rotate " . Shell::escape( "-$rotation" ) . " " .
603                    Shell::escape( $this->escapeMagickOutput( $params['dstPath'] ) );
604                wfDebug( __METHOD__ . ": running ImageMagick: $cmd" );
605                $retval = 0;
606                $err = wfShellExecWithStderr( $cmd, $retval );
607                if ( $retval !== 0 ) {
608                    $this->logErrorForExternalProcess( $retval, $err, $cmd );
609
610                    return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
611                }
612
613                return false;
614            case 'imext':
615                $im = new Imagick();
616                $im->readImage( $params['srcPath'] );
617                if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
618                    return new MediaTransformError( 'thumbnail_error', 0, 0,
619                        "Error rotating $rotation degrees" );
620                }
621                $result = $im->writeImage( $params['dstPath'] );
622                if ( !$result ) {
623                    return new MediaTransformError( 'thumbnail_error', 0, 0,
624                        "Unable to write image to {$params['dstPath']}" );
625                }
626
627                return false;
628            default:
629                return new MediaTransformError( 'thumbnail_error', 0, 0,
630                    "$scaler rotation not implemented" );
631        }
632    }
633}