MediaWiki  master
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  $src = Shell::escape( $params['srcPath'] );
392  $dst = Shell::escape( $params['dstPath'] );
393  $cmd = $customConvertCommand;
394  $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames
395  $cmd = str_replace( '%h', Shell::escape( $params['physicalHeight'] ),
396  str_replace( '%w', Shell::escape( $params['physicalWidth'] ), $cmd ) ); # Size
397  wfDebug( __METHOD__ . ": Running custom convert command $cmd" );
398  $retval = 0;
399  $err = wfShellExecWithStderr( $cmd, $retval );
400 
401  if ( $retval !== 0 ) {
402  $this->logErrorForExternalProcess( $retval, $err, $cmd );
403 
404  return $this->getMediaTransformError( $params, $err );
405  }
406 
407  return false; # No error
408  }
409 
418  protected function transformGd( $image, $params ) {
419  # Use PHP's builtin GD library functions.
420  # First find out what kind of file this is, and select the correct
421  # input routine for this.
422 
423  $typemap = [
424  'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ],
425  'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true,
426  [ __CLASS__, 'imageJpegWrapper' ] ],
427  'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ],
428  'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ],
429  'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ],
430  ];
431 
432  if ( !isset( $typemap[$params['mimeType']] ) ) {
433  $err = 'Image type not supported';
434  wfDebug( $err );
435  $errMsg = wfMessage( 'thumbnail_image-type' )->text();
436 
437  return $this->getMediaTransformError( $params, $errMsg );
438  }
439  list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']];
440 
441  if ( !function_exists( $loader ) ) {
442  $err = "Incomplete GD library configuration: missing function $loader";
443  wfDebug( $err );
444  $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text();
445 
446  return $this->getMediaTransformError( $params, $errMsg );
447  }
448 
449  if ( !file_exists( $params['srcPath'] ) ) {
450  $err = "File seems to be missing: {$params['srcPath']}";
451  wfDebug( $err );
452  $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text();
453 
454  return $this->getMediaTransformError( $params, $errMsg );
455  }
456 
457  if ( filesize( $params['srcPath'] ) === 0 ) {
458  $err = "Image file size seems to be zero.";
459  wfDebug( $err );
460  $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text();
461 
462  return $this->getMediaTransformError( $params, $errMsg );
463  }
464 
465  $src_image = $loader( $params['srcPath'] );
466 
467  $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ?
468  $this->getRotation( $image ) :
469  0;
470  list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
471  $dst_image = imagecreatetruecolor( $width, $height );
472 
473  // Initialise the destination image to transparent instead of
474  // the default solid black, to support PNG and GIF transparency nicely
475  $background = imagecolorallocate( $dst_image, 0, 0, 0 );
476  imagecolortransparent( $dst_image, $background );
477  imagealphablending( $dst_image, false );
478 
479  if ( $colorStyle == 'palette' ) {
480  // Don't resample for paletted GIF images.
481  // It may just uglify them, and completely breaks transparency.
482  imagecopyresized( $dst_image, $src_image,
483  0, 0, 0, 0,
484  $width, $height,
485  imagesx( $src_image ), imagesy( $src_image ) );
486  } else {
487  imagecopyresampled( $dst_image, $src_image,
488  0, 0, 0, 0,
489  $width, $height,
490  imagesx( $src_image ), imagesy( $src_image ) );
491  }
492 
493  if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) {
494  $rot_image = imagerotate( $dst_image, $rotation, 0 );
495  imagedestroy( $dst_image );
496  $dst_image = $rot_image;
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  $saveType( ...$funcParams );
506 
507  imagedestroy( $dst_image );
508  imagedestroy( $src_image );
509 
510  return false; # No error
511  }
512 
522  public static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) {
523  $jpegQuality = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::JpegQuality );
524 
525  if ( $quality === null ) {
526  $quality = $jpegQuality;
527  }
528 
529  imageinterlace( $dst_image );
530  imagejpeg( $dst_image, $thumbPath, $quality );
531  }
532 
539  public function canRotate() {
540  $scaler = $this->getScalerType( null, false );
541  switch ( $scaler ) {
542  case 'im':
543  # ImageMagick supports autorotation
544  return true;
545  case 'imext':
546  # Imagick::rotateImage
547  return true;
548  case 'gd':
549  # GD's imagerotate function is used to rotate images, but not
550  # all precompiled PHP versions have that function
551  return function_exists( 'imagerotate' );
552  default:
553  # Other scalers don't support rotation
554  return false;
555  }
556  }
557 
563  public function autoRotateEnabled() {
564  $enableAutoRotation = MediaWikiServices::getInstance()->getMainConfig()
565  ->get( MainConfigNames::EnableAutoRotation );
566 
567  if ( $enableAutoRotation === null ) {
568  // Only enable auto-rotation when we actually can
569  return $this->canRotate();
570  }
571 
572  return $enableAutoRotation;
573  }
574 
583  public function rotate( $file, $params ) {
584  $imageMagickConvertCommand = MediaWikiServices::getInstance()
585  ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
586 
587  $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
588  $scene = false;
589 
590  $scaler = $this->getScalerType( null, false );
591  switch ( $scaler ) {
592  case 'im':
593  $cmd = Shell::escape( $imageMagickConvertCommand ) . " " .
594  Shell::escape( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
595  " -rotate " . Shell::escape( "-$rotation" ) . " " .
596  Shell::escape( $this->escapeMagickOutput( $params['dstPath'] ) );
597  wfDebug( __METHOD__ . ": running ImageMagick: $cmd" );
598  $retval = 0;
599  $err = wfShellExecWithStderr( $cmd, $retval );
600  if ( $retval !== 0 ) {
601  $this->logErrorForExternalProcess( $retval, $err, $cmd );
602 
603  return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
604  }
605 
606  return false;
607  case 'imext':
608  $im = new Imagick();
609  $im->readImage( $params['srcPath'] );
610  if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
611  return new MediaTransformError( 'thumbnail_error', 0, 0,
612  "Error rotating $rotation degrees" );
613  }
614  $result = $im->writeImage( $params['dstPath'] );
615  if ( !$result ) {
616  return new MediaTransformError( 'thumbnail_error', 0, 0,
617  "Unable to write image to {$params['dstPath']}" );
618  }
619 
620  return false;
621  default:
622  return new MediaTransformError( 'thumbnail_error', 0, 0,
623  "$scaler rotation not implemented" );
624  }
625  }
626 }
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.Array of parameters that...
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.The parameter string without file n...
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.
Definition: MWException.php:29
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