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