MediaWiki  master
BitmapHandler.php
Go to the documentation of this file.
1 <?php
25 
32 
41  protected function getScalerType( $dstPath, $checkDstPath = true ) {
43 
44  if ( !$dstPath && $checkDstPath ) {
45  # No output path available, client side scaling only
46  $scaler = 'client';
47  } elseif ( !$wgUseImageResize ) {
48  $scaler = 'client';
49  } elseif ( $wgUseImageMagick ) {
50  $scaler = 'im';
51  } elseif ( $wgCustomConvertCommand ) {
52  $scaler = 'custom';
53  } elseif ( function_exists( 'imagecreatetruecolor' ) ) {
54  $scaler = 'gd';
55  } elseif ( class_exists( 'Imagick' ) ) {
56  $scaler = 'imext';
57  } else {
58  $scaler = 'client';
59  }
60 
61  return $scaler;
62  }
63 
64  public function makeParamString( $params ) {
65  $res = parent::makeParamString( $params );
66  if ( isset( $params['interlace'] ) && $params['interlace'] ) {
67  return "interlaced-{$res}";
68  } else {
69  return $res;
70  }
71  }
72 
73  public function parseParamString( $str ) {
74  $remainder = preg_replace( '/^interlaced-/', '', $str );
75  $params = parent::parseParamString( $remainder );
76  if ( $params === false ) {
77  return false;
78  }
79  $params['interlace'] = $str !== $remainder;
80  return $params;
81  }
82 
83  public function validateParam( $name, $value ) {
84  if ( $name === 'interlace' ) {
85  return $value === false || $value === true;
86  } else {
87  return parent::validateParam( $name, $value );
88  }
89  }
90 
96  public function normaliseParams( $image, &$params ) {
98  if ( !parent::normaliseParams( $image, $params ) ) {
99  return false;
100  }
101  $mimeType = $image->getMimeType();
102  $interlace = isset( $params['interlace'] ) && $params['interlace']
103  && isset( $wgMaxInterlacingAreas[$mimeType] )
104  && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType];
105  $params['interlace'] = $interlace;
106  return true;
107  }
108 
115  protected function imageMagickSubsampling( $pixelFormat ) {
116  switch ( $pixelFormat ) {
117  case 'yuv444':
118  return [ '1x1', '1x1', '1x1' ];
119  case 'yuv422':
120  return [ '2x1', '1x1', '1x1' ];
121  case 'yuv420':
122  return [ '2x2', '1x1', '1x1' ];
123  default:
124  throw new MWException( 'Invalid pixel format for JPEG output' );
125  }
126  }
127 
136  protected function transformImageMagick( $image, $params ) {
137  # use ImageMagick
141 
142  $quality = [];
143  $sharpen = [];
144  $scene = false;
145  $animation_pre = [];
146  $animation_post = [];
147  $decoderHint = [];
148  $subsampling = [];
149 
150  if ( $params['mimeType'] == 'image/jpeg' ) {
151  $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
152  $quality = [ '-quality', $qualityVal ?: (string)$wgJpegQuality ]; // 80% by default
153  if ( $params['interlace'] ) {
154  $animation_post = [ '-interlace', 'JPEG' ];
155  }
156  # Sharpening, see T8193
157  if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
158  / ( $params['srcWidth'] + $params['srcHeight'] )
159  < $wgSharpenReductionThreshold
160  ) {
161  $sharpen = [ '-sharpen', $wgSharpenParameter ];
162  }
163  if ( version_compare( $this->getMagickVersion(), "6.5.6" ) >= 0 ) {
164  // JPEG decoder hint to reduce memory, available since IM 6.5.6-2
165  $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ];
166  }
167  if ( $wgJpegPixelFormat ) {
168  $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
169  $subsampling = [ '-sampling-factor', implode( ',', $factors ) ];
170  }
171  } elseif ( $params['mimeType'] == 'image/png' ) {
172  $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
173  if ( $params['interlace'] ) {
174  $animation_post = [ '-interlace', 'PNG' ];
175  }
176  } elseif ( $params['mimeType'] == 'image/webp' ) {
177  $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering
178  } elseif ( $params['mimeType'] == 'image/gif' ) {
179  if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) {
180  // Extract initial frame only; we're so big it'll
181  // be a total drag. :P
182  $scene = 0;
183  } elseif ( $this->isAnimatedImage( $image ) ) {
184  // Coalesce is needed to scale animated GIFs properly (T3017).
185  $animation_pre = [ '-coalesce' ];
186  // We optimize the output, but -optimize is broken,
187  // use optimizeTransparency instead (T13822)
188  if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) {
189  $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ];
190  }
191  }
192  if ( $params['interlace'] && version_compare( $this->getMagickVersion(), "6.3.4" ) >= 0
193  && !$this->isAnimatedImage( $image ) ) { // interlacing animated GIFs is a bad idea
194  $animation_post[] = '-interlace';
195  $animation_post[] = 'GIF';
196  }
197  } elseif ( $params['mimeType'] == 'image/x-xcf' ) {
198  // Before merging layers, we need to set the background
199  // to be transparent to preserve alpha, as -layers merge
200  // merges all layers on to a canvas filled with the
201  // background colour. After merging we reset the background
202  // to be white for the default background colour setting
203  // in the PNG image (which is used in old IE)
204  $animation_pre = [
205  '-background', 'transparent',
206  '-layers', 'merge',
207  '-background', 'white',
208  ];
209  Wikimedia\suppressWarnings();
210  $xcfMeta = unserialize( $image->getMetadata() );
211  Wikimedia\restoreWarnings();
212  if ( $xcfMeta
213  && isset( $xcfMeta['colorType'] )
214  && $xcfMeta['colorType'] === 'greyscale-alpha'
215  && version_compare( $this->getMagickVersion(), "6.8.9-3" ) < 0
216  ) {
217  // T68323 - Greyscale images not rendered properly.
218  // So only take the "red" channel.
219  $channelOnly = [ '-channel', 'R', '-separate' ];
220  $animation_pre = array_merge( $animation_pre, $channelOnly );
221  }
222  }
223 
224  // Use one thread only, to avoid deadlock bugs on OOM
225  $env = [ 'OMP_NUM_THREADS' => 1 ];
226  if ( strval( $wgImageMagickTempDir ) !== '' ) {
227  $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir;
228  }
229 
230  $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
231  list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
232 
233  $cmd = Shell::escape( ...array_merge(
234  [ $wgImageMagickConvertCommand ],
235  $quality,
236  // Specify white background color, will be used for transparent images
237  // in Internet Explorer/Windows instead of default black.
238  [ '-background', 'white' ],
239  $decoderHint,
240  [ $this->escapeMagickInput( $params['srcPath'], $scene ) ],
241  $animation_pre,
242  // For the -thumbnail option a "!" is needed to force exact size,
243  // or ImageMagick may decide your ratio is wrong and slice off
244  // a pixel.
245  [ '-thumbnail', "{$width}x{$height}!" ],
246  // Add the source url as a comment to the thumb, but don't add the flag if there's no comment
247  ( $params['comment'] !== ''
248  ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ]
249  : [] ),
250  // T108616: Avoid exposure of local file path
251  [ '+set', 'Thumb::URI' ],
252  [ '-depth', 8 ],
253  $sharpen,
254  [ '-rotate', "-$rotation" ],
255  $subsampling,
256  $animation_post,
257  [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) );
258 
259  wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" );
260  $retval = 0;
261  $err = wfShellExecWithStderr( $cmd, $retval, $env );
262 
263  if ( $retval !== 0 ) {
264  $this->logErrorForExternalProcess( $retval, $err, $cmd );
265 
266  return $this->getMediaTransformError( $params, "$err\nError code: $retval" );
267  }
268 
269  return false; # No error
270  }
271 
280  protected function transformImageMagickExt( $image, $params ) {
283 
284  try {
285  $im = new Imagick();
286  $im->readImage( $params['srcPath'] );
287 
288  if ( $params['mimeType'] == 'image/jpeg' ) {
289  // Sharpening, see T8193
290  if ( ( $params['physicalWidth'] + $params['physicalHeight'] )
291  / ( $params['srcWidth'] + $params['srcHeight'] )
292  < $wgSharpenReductionThreshold
293  ) {
294  // Hack, since $wgSharpenParameter is written specifically for the command line convert
295  list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter );
296  $im->sharpenImage( $radius, $sigma );
297  }
298  $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null;
299  $im->setCompressionQuality( $qualityVal ?: $wgJpegQuality );
300  if ( $params['interlace'] ) {
301  $im->setInterlaceScheme( Imagick::INTERLACE_JPEG );
302  }
303  if ( $wgJpegPixelFormat ) {
304  $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat );
305  $im->setSamplingFactors( $factors );
306  }
307  } elseif ( $params['mimeType'] == 'image/png' ) {
308  $im->setCompressionQuality( 95 );
309  if ( $params['interlace'] ) {
310  $im->setInterlaceScheme( Imagick::INTERLACE_PNG );
311  }
312  } elseif ( $params['mimeType'] == 'image/gif' ) {
313  if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) {
314  // Extract initial frame only; we're so big it'll
315  // be a total drag. :P
316  $im->setImageScene( 0 );
317  } elseif ( $this->isAnimatedImage( $image ) ) {
318  // Coalesce is needed to scale animated GIFs properly (T3017).
319  $im = $im->coalesceImages();
320  }
321  // GIF interlacing is only available since 6.3.4
322  $v = Imagick::getVersion();
323  preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $v );
324 
325  if ( $params['interlace'] && version_compare( $v[1], '6.3.4' ) >= 0 ) {
326  $im->setInterlaceScheme( Imagick::INTERLACE_GIF );
327  }
328  }
329 
330  $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image );
331  list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
332 
333  $im->setImageBackgroundColor( new ImagickPixel( 'white' ) );
334 
335  // Call Imagick::thumbnailImage on each frame
336  foreach ( $im as $i => $frame ) {
337  if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) {
338  return $this->getMediaTransformError( $params, "Error scaling frame $i" );
339  }
340  }
341  $im->setImageDepth( 8 );
342 
343  if ( $rotation && !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
344  return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" );
345  }
346 
347  if ( $this->isAnimatedImage( $image ) ) {
348  wfDebug( __METHOD__ . ": Writing animated thumbnail\n" );
349  // This is broken somehow... can't find out how to fix it
350  $result = $im->writeImages( $params['dstPath'], true );
351  } else {
352  $result = $im->writeImage( $params['dstPath'] );
353  }
354  if ( !$result ) {
355  return $this->getMediaTransformError( $params,
356  "Unable to write thumbnail to {$params['dstPath']}" );
357  }
358  } catch ( ImagickException $e ) {
359  return $this->getMediaTransformError( $params, $e->getMessage() );
360  }
361 
362  return false;
363  }
364 
373  protected function transformCustom( $image, $params ) {
374  # Use a custom convert command
376 
377  # Variables: %s %d %w %h
378  $src = Shell::escape( $params['srcPath'] );
379  $dst = Shell::escape( $params['dstPath'] );
381  $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames
382  $cmd = str_replace( '%h', Shell::escape( $params['physicalHeight'] ),
383  str_replace( '%w', Shell::escape( $params['physicalWidth'] ), $cmd ) ); # Size
384  wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" );
385  $retval = 0;
386  $err = wfShellExecWithStderr( $cmd, $retval );
387 
388  if ( $retval !== 0 ) {
389  $this->logErrorForExternalProcess( $retval, $err, $cmd );
390 
391  return $this->getMediaTransformError( $params, $err );
392  }
393 
394  return false; # No error
395  }
396 
405  protected function transformGd( $image, $params ) {
406  # Use PHP's builtin GD library functions.
407  # First find out what kind of file this is, and select the correct
408  # input routine for this.
409 
410  $typemap = [
411  'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ],
412  'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true,
413  [ __CLASS__, 'imageJpegWrapper' ] ],
414  'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ],
415  'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ],
416  'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ],
417  ];
418 
419  if ( !isset( $typemap[$params['mimeType']] ) ) {
420  $err = 'Image type not supported';
421  wfDebug( "$err\n" );
422  $errMsg = wfMessage( 'thumbnail_image-type' )->text();
423 
424  return $this->getMediaTransformError( $params, $errMsg );
425  }
426  list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']];
427 
428  if ( !function_exists( $loader ) ) {
429  $err = "Incomplete GD library configuration: missing function $loader";
430  wfDebug( "$err\n" );
431  $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text();
432 
433  return $this->getMediaTransformError( $params, $errMsg );
434  }
435 
436  if ( !file_exists( $params['srcPath'] ) ) {
437  $err = "File seems to be missing: {$params['srcPath']}";
438  wfDebug( "$err\n" );
439  $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text();
440 
441  return $this->getMediaTransformError( $params, $errMsg );
442  }
443 
444  if ( filesize( $params['srcPath'] ) === 0 ) {
445  $err = "Image file size seems to be zero.";
446  wfDebug( "$err\n" );
447  $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text();
448 
449  return $this->getMediaTransformError( $params, $errMsg );
450  }
451 
452  $src_image = $loader( $params['srcPath'] );
453 
454  $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ?
455  $this->getRotation( $image ) :
456  0;
457  list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation );
458  $dst_image = imagecreatetruecolor( $width, $height );
459 
460  // Initialise the destination image to transparent instead of
461  // the default solid black, to support PNG and GIF transparency nicely
462  $background = imagecolorallocate( $dst_image, 0, 0, 0 );
463  imagecolortransparent( $dst_image, $background );
464  imagealphablending( $dst_image, false );
465 
466  if ( $colorStyle == 'palette' ) {
467  // Don't resample for paletted GIF images.
468  // It may just uglify them, and completely breaks transparency.
469  imagecopyresized( $dst_image, $src_image,
470  0, 0, 0, 0,
471  $width, $height,
472  imagesx( $src_image ), imagesy( $src_image ) );
473  } else {
474  imagecopyresampled( $dst_image, $src_image,
475  0, 0, 0, 0,
476  $width, $height,
477  imagesx( $src_image ), imagesy( $src_image ) );
478  }
479 
480  if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) {
481  $rot_image = imagerotate( $dst_image, $rotation, 0 );
482  imagedestroy( $dst_image );
483  $dst_image = $rot_image;
484  }
485 
486  imagesavealpha( $dst_image, true );
487 
488  $funcParams = [ $dst_image, $params['dstPath'] ];
489  if ( $useQuality && isset( $params['quality'] ) ) {
490  $funcParams[] = $params['quality'];
491  }
492  $saveType( ...$funcParams );
493 
494  imagedestroy( $dst_image );
495  imagedestroy( $src_image );
496 
497  return false; # No error
498  }
499 
508  static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) {
509  global $wgJpegQuality;
510 
511  if ( $quality === null ) {
512  $quality = $wgJpegQuality;
513  }
514 
515  imageinterlace( $dst_image );
516  imagejpeg( $dst_image, $thumbPath, $quality );
517  }
518 
524  public function canRotate() {
525  $scaler = $this->getScalerType( null, false );
526  switch ( $scaler ) {
527  case 'im':
528  # ImageMagick supports autorotation
529  return true;
530  case 'imext':
531  # Imagick::rotateImage
532  return true;
533  case 'gd':
534  # GD's imagerotate function is used to rotate images, but not
535  # all precompiled PHP versions have that function
536  return function_exists( 'imagerotate' );
537  default:
538  # Other scalers don't support rotation
539  return false;
540  }
541  }
542 
547  public function autoRotateEnabled() {
548  global $wgEnableAutoRotation;
549 
550  if ( $wgEnableAutoRotation === null ) {
551  // Only enable auto-rotation when we actually can
552  return $this->canRotate();
553  }
554 
555  return $wgEnableAutoRotation;
556  }
557 
565  public function rotate( $file, $params ) {
567 
568  $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360;
569  $scene = false;
570 
571  $scaler = $this->getScalerType( null, false );
572  switch ( $scaler ) {
573  case 'im':
574  $cmd = Shell::escape( $wgImageMagickConvertCommand ) . " " .
575  Shell::escape( $this->escapeMagickInput( $params['srcPath'], $scene ) ) .
576  " -rotate " . Shell::escape( "-$rotation" ) . " " .
577  Shell::escape( $this->escapeMagickOutput( $params['dstPath'] ) );
578  wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" );
579  $retval = 0;
580  $err = wfShellExecWithStderr( $cmd, $retval );
581  if ( $retval !== 0 ) {
582  $this->logErrorForExternalProcess( $retval, $err, $cmd );
583 
584  return new MediaTransformError( 'thumbnail_error', 0, 0, $err );
585  }
586 
587  return false;
588  case 'imext':
589  $im = new Imagick();
590  $im->readImage( $params['srcPath'] );
591  if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) {
592  return new MediaTransformError( 'thumbnail_error', 0, 0,
593  "Error rotating $rotation degrees" );
594  }
595  $result = $im->writeImage( $params['dstPath'] );
596  if ( !$result ) {
597  return new MediaTransformError( 'thumbnail_error', 0, 0,
598  "Unable to write image to {$params['dstPath']}" );
599  }
600 
601  return false;
602  default:
603  return new MediaTransformError( 'thumbnail_error', 0, 0,
604  "$scaler rotation not implemented" );
605  }
606  }
607 }
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick&#39;s input filenames.
static imageJpegWrapper( $dst_image, $thumbPath, $quality=null)
Callback for transformGd when transforming jpeg images.
$wgImageMagickConvertCommand
The convert command shipped with ImageMagick.
$wgImageMagickTempDir
Temporary directory used for ImageMagick.
rotate( $file, $params)
Handler for images that need to be transformed.
$wgUseImageResize
Whether to enable server-side image thumbnailing.
$wgJpegPixelFormat
At default setting of &#39;yuv420&#39;, JPEG thumbnails will use 4:2:0 chroma subsampling to reduce file size...
transformGd( $image, $params)
Transform an image using the built in GD library.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick&#39;s output filename.
$wgSharpenParameter
Sharpening parameter to ImageMagick.
$wgUseImageMagick
Resizing can be done using PHP&#39;s internal image libraries or using ImageMagick or another third-party...
escapeMagickProperty( $s)
Escape a string for ImageMagick&#39;s property input (e.g.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error &#39;thumbnail_error&#39;.
transformCustom( $image, $params)
Transform an image using a custom command.
isAnimatedImage( $file)
The material is an image, and is animated.
canRotate()
Returns whether the current scaler supports rotation (im and gd do)
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
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...
unserialize( $serialized)
$wgMaxInterlacingAreas
Array of max pixel areas for interlacing per MIME type.
$wgJpegQuality
When scaling a JPEG thumbnail, this is the quality we request from the backend.
$wgCustomConvertCommand
Use another resizing converter, e.g.
makeParamString( $params)
logErrorForExternalProcess( $retval, $err, $cmd)
Log an error that occurred in an external process.
imageMagickSubsampling( $pixelFormat)
Get ImageMagick subsampling factors for the target JPEG pixel format.
wfShellExecWithStderr( $cmd, &$retval=null, $environ=[], $limits=[])
Execute a shell command, returning both stdout and stderr.
$wgMaxAnimatedGifArea
Force thumbnailing of animated GIFs above this size to a single frame instead of an animated thumbnai...
getScalerType( $dstPath, $checkDstPath=true)
Returns which scaler type should be used.
parseParamString( $str)
Generic handler for bitmap images.
getMagickVersion()
Retrieve the version of the installed ImageMagick You can use PHPs version_compare() to use this valu...
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
$wgSharpenReductionThreshold
Reduction in linear dimensions below which sharpening will be enabled.
normaliseParams( $image, &$params)
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
validateParam( $name, $value)
$wgEnableAutoRotation
If set to true, images that contain certain the exif orientation tag will be rotated accordingly...
Basic media transform error class.