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