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