Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
30.94% covered (danger)
30.94%
99 / 320
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
BitmapHandler
31.03% covered (danger)
31.03%
99 / 319
6.67% covered (danger)
6.67%
1 / 15
3721.38
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
4.25
 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
69.77% covered (warning)
69.77%
60 / 86
0.00% covered (danger)
0.00%
0 / 1
33.19
 transformImageMagickExt
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
600
 transformCustom
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 transformGd
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
210
 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 / 31
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 ( $res && 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'] = (string)$imageMagickTempDir;
241        }
242
243        $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
244        [ $width, $height ] = $this->extractPreRotationDimensions( $params, $rotation );
245        $mirroring = isset( $params['disableRotation'] ) ? null : $this->getMirrored( $image );
246        $mirrored = match ( $mirroring ) {
247            'horizontal' => [ '-flop' ],
248            'vertical' => [ '-flip' ],
249            default => [],
250        };
251
252        $cmd = Shell::escape( ...array_merge(
253            [ $imageMagickConvertCommand ],
254            $quality,
255            // Specify white background color, will be used for transparent images
256            // in Internet Explorer/Windows instead of default black.
257            [ '-background', 'white' ],
258            $decoderHint,
259            [ $this->escapeMagickInput( $params['srcPath'], $scene ) ],
260            $animation_pre,
261            // For the -thumbnail option a "!" is needed to force exact size,
262            // or ImageMagick may decide your ratio is wrong and slice off
263            // a pixel.
264            [ '-thumbnail', "{$width}x{$height}!" ],
265            // Add the source url as a comment to the thumb, but don't add the flag if there's no comment
266            ( $params['comment'] !== ''
267                ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ]
268                : [] ),
269            // T108616: Avoid exposure of local file path
270            [ '+set', 'Thumb::URI' ],
271            [ '-depth', 8 ],
272            $sharpen,
273            [ '-rotate', "-$rotation" ],
274            $mirrored,
275            $subsampling,
276            $animation_post,
277            [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) );
278
279        wfDebug( __METHOD__ . ": running ImageMagick: $cmd" );
280        $shell = Shell::command()->unsafeCommand( $cmd )->environment( $env )->execute();
281        $retval = $shell->getExitCode();
282        $err = $shell->getStderr();
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'] ) ? (int)$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 ( !isset( $params['disableRotation'] ) ) {
369                switch ( $this->getMirrored( $image ) ) {
370                    case 'horizontal':
371                        $im->flopImage();
372                        break;
373                    case 'vertical':
374                        $im->flipImage();
375                        break;
376                }
377            }
378
379            if ( $this->isAnimatedImage( $image ) ) {
380                wfDebug( __METHOD__ . ": Writing animated thumbnail" );
381                // This is broken somehow... can't find out how to fix it
382                $result = $im->writeImages( $params['dstPath'], true );
383            } else {
384                $result = $im->writeImage( $params['dstPath'] );
385            }
386            if ( !$result ) {
387                return $this->getMediaTransformError( $params,
388                    "Unable to write thumbnail to {$params['dstPath']}" );
389            }
390        } catch ( ImagickException $e ) {
391            return $this->getMediaTransformError( $params, $e->getMessage() );
392        }
393
394        return false;
395    }
396
397    /**
398     * Transform an image using a custom command
399     *
400     * @param File $image File associated with this thumbnail
401     * @param array $params Array with scaler params
402     *
403     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
404     */
405    protected function transformCustom( $image, $params ) {
406        // Use a custom convert command
407        $customConvertCommand = MediaWikiServices::getInstance()->getMainConfig()
408            ->get( MainConfigNames::CustomConvertCommand );
409
410        // Find all variables in the original command at once,
411        // so that replacement values cannot inject variable placeholders
412        $cmd = strtr( $customConvertCommand, [
413            '%s' => Shell::escape( $params['srcPath'] ),
414            '%d' => Shell::escape( $params['dstPath'] ),
415            '%w' => Shell::escape( $params['physicalWidth'] ),
416            '%h' => Shell::escape( $params['physicalHeight'] ),
417        ] );
418        wfDebug( __METHOD__ . ": Running custom convert command $cmd" );
419        $shell = Shell::command()->unsafeCommand( $cmd )->execute();
420        $retval = $shell->getExitCode();
421        $err = $shell->getStderr();
422
423        if ( $retval !== 0 ) {
424            $this->logErrorForExternalProcess( $retval, $err, $cmd );
425
426            return $this->getMediaTransformError( $params, $err );
427        }
428
429        return false; # No error
430    }
431
432    /**
433     * Transform an image using the built in GD library
434     *
435     * @param File $image File associated with this thumbnail
436     * @param array $params Array with scaler params
437     *
438     * @return MediaTransformError|false Error object if error occurred, false (=no error) otherwise
439     */
440    protected function transformGd( $image, $params ) {
441        # Use PHP's builtin GD library functions.
442        # First find out what kind of file this is, and select the correct
443        # input routine for this.
444
445        $typemap = [
446            'image/gif' => [ 'imagecreatefromgif', 'palette', false, imagegif( ... ) ],
447            'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true,
448                self::imageJpegWrapper( ... ) ],
449            'image/png' => [ 'imagecreatefrompng', 'bits', false, imagepng( ... ) ],
450            'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, imagewbmp( ... ) ],
451            'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, imagexbm( ... ) ],
452        ];
453
454        if ( !isset( $typemap[$params['mimeType']] ) ) {
455            $err = 'Image type not supported';
456            wfDebug( $err );
457            $errMsg = wfMessage( 'thumbnail_image-type' )->text();
458
459            return $this->getMediaTransformError( $params, $errMsg );
460        }
461        [ $loader, $colorStyle, $useQuality, $saveType ] = $typemap[$params['mimeType']];
462
463        if ( !function_exists( $loader ) ) {
464            $err = "Incomplete GD library configuration: missing function $loader";
465            wfDebug( $err );
466            $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text();
467
468            return $this->getMediaTransformError( $params, $errMsg );
469        }
470
471        if ( !file_exists( $params['srcPath'] ) ) {
472            $err = "File seems to be missing: {$params['srcPath']}";
473            wfDebug( $err );
474            $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text();
475
476            return $this->getMediaTransformError( $params, $errMsg );
477        }
478
479        if ( filesize( $params['srcPath'] ) === 0 ) {
480            $err = "Image file size seems to be zero.";
481            wfDebug( $err );
482            $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text();
483
484            return $this->getMediaTransformError( $params, $errMsg );
485        }
486
487        $src_image = $loader( $params['srcPath'] );
488
489        $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ?
490            $this->getRotation( $image ) :
491            0;
492        [ $width, $height ] = $this->extractPreRotationDimensions( $params, $rotation );
493        $dst_image = imagecreatetruecolor( $width, $height );
494
495        // Initialise the destination image to transparent instead of
496        // the default solid black, to support PNG and GIF transparency nicely
497        $background = imagecolorallocate( $dst_image, 0, 0, 0 );
498        imagecolortransparent( $dst_image, $background );
499        imagealphablending( $dst_image, false );
500
501        if ( $colorStyle === 'palette' ) {
502            // Don't resample for paletted GIF images.
503            // It may just uglify them, and completely breaks transparency.
504            imagecopyresized( $dst_image, $src_image,
505                0, 0, 0, 0,
506                $width, $height,
507                imagesx( $src_image ), imagesy( $src_image ) );
508        } else {
509            imagecopyresampled( $dst_image, $src_image,
510                0, 0, 0, 0,
511                $width, $height,
512                imagesx( $src_image ), imagesy( $src_image ) );
513        }
514
515        if ( $rotation % 360 !== 0 ) {
516            $dst_image = imagerotate( $dst_image, $rotation, 0 );
517        }
518
519        if ( !isset( $params['disableRotation'] ) ) {
520            switch ( $this->getMirrored( $image ) ) {
521                case 'horizontal':
522                    imageflip( $dst_image, IMG_FLIP_HORIZONTAL );
523                    break;
524                case 'vertical':
525                    imageflip( $dst_image, IMG_FLIP_VERTICAL );
526                    break;
527            }
528        }
529
530        imagesavealpha( $dst_image, true );
531
532        $funcParams = [ $dst_image, $params['dstPath'] ];
533        if ( $useQuality && isset( $params['quality'] ) ) {
534            $funcParams[] = $params['quality'];
535        }
536        // @phan-suppress-next-line PhanParamTooFewInternalUnpack,PhanParamTooFewUnpack There are at least 2 args
537        $saveType( ...$funcParams );
538
539        return false; # No error
540    }
541
542    /**
543     * Callback for transformGd when transforming jpeg images.
544     *
545     * @phpcs:ignore MediaWiki.Commenting.FunctionComment.ObjectTypeHintParam
546     * @param resource|object $dst_image Image resource of the original image
547     * @param string $thumbPath File path to write the thumbnail image to
548     * @param int|null $quality Quality of the thumbnail from 1-100,
549     *    or null to use default quality.
550     */
551    public static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) {
552        $jpegQuality = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::JpegQuality );
553
554        imageinterlace( $dst_image );
555        imagejpeg( $dst_image, $thumbPath, $quality ?? $jpegQuality );
556    }
557
558    /**
559     * Returns whether the current scaler supports rotation (im and gd do)
560     * @stable to override
561     *
562     * @return bool
563     */
564    public function canRotate() {
565        $scaler = $this->getScalerType( null, false );
566        switch ( $scaler ) {
567            case 'im':
568                # ImageMagick supports autorotation
569                return true;
570            case 'imext':
571                # Imagick::rotateImage
572                return true;
573            case 'gd':
574                # GD's imagerotate function is used to rotate images, but not
575                # all precompiled PHP versions have that function
576                return function_exists( 'imagerotate' );
577            default:
578                # Other scalers don't support rotation
579                return false;
580        }
581    }
582
583    /**
584     * @see $wgEnableAutoRotation
585     * @stable to override
586     * @return bool Whether auto rotation is enabled
587     */
588    public function autoRotateEnabled() {
589        $enableAutoRotation = MediaWikiServices::getInstance()->getMainConfig()
590            ->get( MainConfigNames::EnableAutoRotation );
591
592        if ( $enableAutoRotation === null ) {
593            // Only enable auto-rotation when we actually can
594            return $this->canRotate();
595        }
596
597        return $enableAutoRotation;
598    }
599
600    /**
601     * @stable to override
602     * @param File $file
603     * @param array{rotation:int,srcPath:string,dstPath:string} $params Rotate parameters.
604     *   'rotation' clockwise rotation in degrees, allowed are multiples of 90
605     * @since 1.21
606     * @return MediaTransformError|false
607     */
608    public function rotate( $file, $params ) {
609        $imageMagickConvertCommand = MediaWikiServices::getInstance()
610            ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
611
612        $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
613        $scene = false;
614
615        $scaler = $this->getScalerType( null, false );
616        switch ( $scaler ) {
617            case 'im':
618                $cmd = Shell::escape( $imageMagickConvertCommand ) . " " .
619                    Shell::escape( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
620                    " -rotate " . Shell::escape( "-$rotation" ) . " " .
621                    Shell::escape( $this->escapeMagickOutput( $params['dstPath'] ) );
622                wfDebug( __METHOD__ . ": running ImageMagick: $cmd" );
623                $shell = Shell::command()->unsafeCommand( $cmd )->execute();
624                $err = $shell->getStderr();
625                $retval = $shell->getExitCode();
626                if ( $retval !== 0 ) {
627                    $this->logErrorForExternalProcess( $retval, $err, $cmd );
628
629                    return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
630                }
631
632                return false;
633            case 'imext':
634                $im = new Imagick();
635                $im->readImage( $params['srcPath'] );
636                if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
637                    return new MediaTransformError( 'thumbnail_error', 0, 0,
638                        "Error rotating $rotation degrees" );
639                }
640                $result = $im->writeImage( $params['dstPath'] );
641                if ( !$result ) {
642                    return new MediaTransformError( 'thumbnail_error', 0, 0,
643                        "Unable to write image to {$params['dstPath']}" );
644                }
645
646                return false;
647            default:
648                return new MediaTransformError( 'thumbnail_error', 0, 0,
649                    "$scaler rotation not implemented" );
650        }
651    }
652}
653
654/** @deprecated class alias since 1.46 */
655class_alias( BitmapHandler::class, 'BitmapHandler' );