MediaWiki master
BitmapHandler.php
Go to the documentation of this file.
1<?php
28
36
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
77 protected function hasGDSupport() {
78 return true;
79 }
80
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
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
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
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
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
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
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
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
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
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
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
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
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}
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfShellExecWithStderr( $cmd, &$retval=null, $environ=[], $limits=[])
Execute a shell command, returning both stdout and stderr.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
Generic handler for bitmap images.
canRotate()
Returns whether the current scaler supports rotation (im and gd do)
rotate( $file, $params)
static imageJpegWrapper( $dst_image, $thumbPath, $quality=null)
Callback for transformGd when transforming jpeg images.
hasGDSupport()
Whether the php-gd extension supports this type of file.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
getScalerType( $dstPath, $checkDstPath=true)
Returns which scaler type should be used.
imageMagickSubsampling( $pixelFormat)
Get ImageMagick subsampling factors for the target JPEG pixel format.
transformCustom( $image, $params)
Transform an image using a custom command.
makeParamString( $params)
Merge a parameter array into a string appropriate for inclusion in filenames.stringto override
transformGd( $image, $params)
Transform an image using the built in GD library.
parseParamString( $str)
Parse a param string made with makeParamString back into an array.array|false Array of parameters or ...
normaliseParams( $image, &$params)
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
validateParam( $name, $value)
Validate a thumbnail parameter at parse time.Return true to accept the parameter, and false to reject...
getImageArea( $image)
Function that returns the number of pixels to be thumbnailed.
getRotation( $file)
On supporting image formats, try to read out the low-level orientation of the file and return the ang...
isAnimatedImage( $file)
The material is an image, and is animated.
logErrorForExternalProcess( $retval, $err, $cmd)
Log an error that occurred in an external process.
Basic media transform error class.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:93
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Executes shell commands.
Definition Shell.php:46
Handler for images that need to be transformed.
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick's input filenames.
escapeMagickProperty( $s)
Escape a string for ImageMagick's property input (e.g.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick's output filename.
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error 'thumbnail_error'.