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