X-Git-Url: https://scripts.mit.edu/gitweb/autoinstallsdev/mediawiki.git/blobdiff_plain/19e297c21b10b1b8a3acad5e73fc71dcb35db44a..6932310fd58ebef145fa01eb76edf7150284d8ea:/includes/media/Bitmap.php diff --git a/includes/media/Bitmap.php b/includes/media/Bitmap.php index f5f7ba6d..ac39e6f3 100644 --- a/includes/media/Bitmap.php +++ b/includes/media/Bitmap.php @@ -1,6 +1,21 @@ getMimeType(); - $srcWidth = $image->getWidth( $params['page'] ); - $srcHeight = $image->getHeight( $params['page'] ); - - # Don't make an image bigger than the source - $params['physicalWidth'] = $params['width']; - $params['physicalHeight'] = $params['height']; - - if ( $params['physicalWidth'] >= $srcWidth ) { - $params['physicalWidth'] = $srcWidth; - $params['physicalHeight'] = $srcHeight; - # Skip scaling limit checks if no scaling is required - if ( !$image->mustRender() ) - return true; - } - - # Don't thumbnail an image so big that it will fill hard drives and send servers into swap - # JPEG has the handy property of allowing thumbnailing without full decompression, so we make - # an exception for it. - # FIXME: This actually only applies to ImageMagick - if ( $mimeType !== 'image/jpeg' && - $srcWidth * $srcHeight > $wgMaxImageArea ) - { - return false; - } - - return true; - } - - - // Function that returns the number of pixels to be thumbnailed. - // Intended for animated GIFs to multiply by the number of frames. - function getImageArea( $image, $width, $height ) { - return $width * $height; - } - - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - global $wgUseImageMagick; - global $wgCustomConvertCommand, $wgUseImageResize; +class BitmapHandler extends TransformationalImageHandler { - if ( !$this->normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - # Create a parameter array to pass to the scaler - $scalerParams = array( - # The size to which the image will be resized - 'physicalWidth' => $params['physicalWidth'], - 'physicalHeight' => $params['physicalHeight'], - 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}", - # The size of the image on the page - 'clientWidth' => $params['width'], - 'clientHeight' => $params['height'], - # Comment as will be added to the EXIF of the thumbnail - 'comment' => isset( $params['descriptionUrl'] ) ? - "File source: {$params['descriptionUrl']}" : '', - # Properties of the original image - 'srcWidth' => $image->getWidth(), - 'srcHeight' => $image->getHeight(), - 'mimeType' => $image->getMimeType(), - 'srcPath' => $image->getPath(), - 'dstPath' => $dstPath, - ); - - wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} thumbnail at $dstPath\n" ); - - if ( !$image->mustRender() && - $scalerParams['physicalWidth'] == $scalerParams['srcWidth'] - && $scalerParams['physicalHeight'] == $scalerParams['srcHeight'] ) { - - # normaliseParams (or the user) wants us to return the unscaled image - wfDebug( __METHOD__ . ": returning unscaled image\n" ); - return $this->getClientScalingThumbnailImage( $image, $scalerParams ); - } + /** + * Returns which scaler type should be used. Creates parent directories + * for $dstPath and returns 'client' on error + * + * @param string $dstPath + * @param bool $checkDstPath + * @return string|Callable One of client, im, custom, gd, imext or an array( object, method ) + */ + protected function getScalerType( $dstPath, $checkDstPath = true ) { + global $wgUseImageResize, $wgUseImageMagick, $wgCustomConvertCommand; - # Determine scaler type - if ( !$dstPath ) { + if ( !$dstPath && $checkDstPath ) { # No output path available, client side scaling only $scaler = 'client'; } elseif ( !$wgUseImageResize ) { @@ -104,169 +50,324 @@ class BitmapHandler extends ImageHandler { $scaler = 'custom'; } elseif ( function_exists( 'imagecreatetruecolor' ) ) { $scaler = 'gd'; + } elseif ( class_exists( 'Imagick' ) ) { + $scaler = 'imext'; } else { $scaler = 'client'; } - wfDebug( __METHOD__ . ": scaler $scaler\n" ); - if ( $scaler == 'client' ) { - # Client-side image scaling, use the source URL - # Using the destination URL in a TRANSFORM_LATER request would be incorrect - return $this->getClientScalingThumbnailImage( $image, $scalerParams ); - } + return $scaler; + } - if ( $flags & self::TRANSFORM_LATER ) { - wfDebug( __METHOD__ . ": Transforming later per flags.\n" ); - return new ThumbnailImage( $image, $dstUrl, $scalerParams['clientWidth'], - $scalerParams['clientHeight'], $dstPath ); + public function makeParamString( $params ) { + $res = parent::makeParamString( $params ); + if ( isset( $params['interlace'] ) && $params['interlace'] ) { + return "interlaced-{$res}"; + } else { + return $res; } + } - # Try to make a target path for the thumbnail - if ( !wfMkdirParents( dirname( $dstPath ) ) ) { - wfDebug( __METHOD__ . ": Unable to create thumbnail destination directory, falling back to client scaling\n" ); - return $this->getClientScalingThumbnailImage( $image, $scalerParams ); + public function parseParamString( $str ) { + $remainder = preg_replace( '/^interlaced-/', '', $str ); + $params = parent::parseParamString( $remainder ); + if ( $params === false ) { + return false; } + $params['interlace'] = $str !== $remainder; + return $params; + } - switch ( $scaler ) { - case 'im': - $err = $this->transformImageMagick( $image, $scalerParams ); - break; - case 'custom': - $err = $this->transformCustom( $image, $scalerParams ); - break; - case 'gd': - default: - $err = $this->transformGd( $image, $scalerParams ); - break; + public function validateParam( $name, $value ) { + if ( $name === 'interlace' ) { + return $value === false || $value === true; + } else { + return parent::validateParam( $name, $value ); } + } - # Remove the file if a zero-byte thumbnail was created, or if there was an error - $removed = $this->removeBadFile( $dstPath, (bool)$err ); - if ( $err ) { - # transform returned MediaTransforError - return $err; - } elseif ( $removed ) { - # Thumbnail was zero-byte and had to be removed - return new MediaTransformError( 'thumbnail_error', - $scalerParams['clientWidth'], $scalerParams['clientHeight'] ); - } else { - return new ThumbnailImage( $image, $dstUrl, $scalerParams['clientWidth'], - $scalerParams['clientHeight'], $dstPath ); + /** + * @param File $image + * @param array &$params + * @return bool + */ + function normaliseParams( $image, &$params ) { + global $wgMaxInterlacingAreas; + if ( !parent::normaliseParams( $image, $params ) ) { + return false; } + $mimeType = $image->getMimeType(); + $interlace = isset( $params['interlace'] ) && $params['interlace'] + && isset( $wgMaxInterlacingAreas[$mimeType] ) + && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType]; + $params['interlace'] = $interlace; + return true; } /** - * Get a ThumbnailImage that respresents an image that will be scaled - * client side + * Get ImageMagick subsampling factors for the target JPEG pixel format. * - * @param $image File File associated with this thumbnail - * @param $params array Array with scaler params - * @return ThumbnailImage + * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' + * @return array of string keys */ - protected function getClientScalingThumbnailImage( $image, $params ) { - return new ThumbnailImage( $image, $image->getURL(), - $params['clientWidth'], $params['clientHeight'], $params['srcPath'] ); + protected function imageMagickSubsampling( $pixelFormat ) { + switch ( $pixelFormat ) { + case 'yuv444': + return [ '1x1', '1x1', '1x1' ]; + case 'yuv422': + return [ '2x1', '1x1', '1x1' ]; + case 'yuv420': + return [ '2x2', '1x1', '1x1' ]; + default: + throw new MWException( 'Invalid pixel format for JPEG output' ); + } } /** * Transform an image using ImageMagick * - * @param $image File File associated with this thumbnail - * @param $params array Array with scaler params + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params * - * @return MediaTransformError Error object if error occured, false (=no error) otherwise + * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise */ protected function transformImageMagick( $image, $params ) { # use ImageMagick - global $wgSharpenReductionThreshold, $wgSharpenParameter, - $wgMaxAnimatedGifArea, - $wgImageMagickTempDir, $wgImageMagickConvertCommand; + global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, + $wgImageMagickTempDir, $wgImageMagickConvertCommand, $wgJpegPixelFormat; - $quality = ''; - $sharpen = ''; + $quality = []; + $sharpen = []; $scene = false; - $animation_pre = ''; - $animation_post = ''; - $decoderHint = ''; + $animation_pre = []; + $animation_post = []; + $decoderHint = []; + $subsampling = []; + if ( $params['mimeType'] == 'image/jpeg' ) { - $quality = "-quality 80"; // 80% - # Sharpening, see bug 6193 + $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; + $quality = [ '-quality', $qualityVal ?: '80' ]; // 80% + if ( $params['interlace'] ) { + $animation_post = [ '-interlace', 'JPEG' ]; + } + # Sharpening, see T8193 if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) - / ( $params['srcWidth'] + $params['srcHeight'] ) - < $wgSharpenReductionThreshold ) { - $sharpen = "-sharpen " . wfEscapeShellArg( $wgSharpenParameter ); + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold + ) { + $sharpen = [ '-sharpen', $wgSharpenParameter ]; + } + if ( version_compare( $this->getMagickVersion(), "6.5.6" ) >= 0 ) { + // JPEG decoder hint to reduce memory, available since IM 6.5.6-2 + $decoderHint = [ '-define', "jpeg:size={$params['physicalDimensions']}" ]; + } + if ( $wgJpegPixelFormat ) { + $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); + $subsampling = [ '-sampling-factor', implode( ',', $factors ) ]; } - // JPEG decoder hint to reduce memory, available since IM 6.5.6-2 - $decoderHint = "-define jpeg:size={$params['physicalDimensions']}"; - } elseif ( $params['mimeType'] == 'image/png' ) { - $quality = "-quality 95"; // zlib 9, adaptive filtering - + $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering + if ( $params['interlace'] ) { + $animation_post = [ '-interlace', 'PNG' ]; + } + } elseif ( $params['mimeType'] == 'image/webp' ) { + $quality = [ '-quality', '95' ]; // zlib 9, adaptive filtering } elseif ( $params['mimeType'] == 'image/gif' ) { - if ( $this->getImageArea( $image, $params['srcWidth'], - $params['srcHeight'] ) > $wgMaxAnimatedGifArea ) { + if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { // Extract initial frame only; we're so big it'll // be a total drag. :P $scene = 0; - } elseif ( $this->isAnimatedImage( $image ) ) { - // Coalesce is needed to scale animated GIFs properly (bug 1017). - $animation_pre = '-coalesce'; + // Coalesce is needed to scale animated GIFs properly (T3017). + $animation_pre = [ '-coalesce' ]; // We optimize the output, but -optimize is broken, - // use optimizeTransparency instead (bug 11822) + // use optimizeTransparency instead (T13822) if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { - $animation_post = '-fuzz 5% -layers optimizeTransparency +map'; + $animation_post = [ '-fuzz', '5%', '-layers', 'optimizeTransparency' ]; } } + if ( $params['interlace'] && version_compare( $this->getMagickVersion(), "6.3.4" ) >= 0 + && !$this->isAnimatedImage( $image ) ) { // interlacing animated GIFs is a bad idea + $animation_post[] = '-interlace'; + $animation_post[] = 'GIF'; + } + } elseif ( $params['mimeType'] == 'image/x-xcf' ) { + // Before merging layers, we need to set the background + // to be transparent to preserve alpha, as -layers merge + // merges all layers on to a canvas filled with the + // background colour. After merging we reset the background + // to be white for the default background colour setting + // in the PNG image (which is used in old IE) + $animation_pre = [ + '-background', 'transparent', + '-layers', 'merge', + '-background', 'white', + ]; + MediaWiki\suppressWarnings(); + $xcfMeta = unserialize( $image->getMetadata() ); + MediaWiki\restoreWarnings(); + if ( $xcfMeta + && isset( $xcfMeta['colorType'] ) + && $xcfMeta['colorType'] === 'greyscale-alpha' + && version_compare( $this->getMagickVersion(), "6.8.9-3" ) < 0 + ) { + // T68323 - Greyscale images not rendered properly. + // So only take the "red" channel. + $channelOnly = [ '-channel', 'R', '-separate' ]; + $animation_pre = array_merge( $animation_pre, $channelOnly ); + } } // Use one thread only, to avoid deadlock bugs on OOM - $env = array( 'OMP_NUM_THREADS' => 1 ); + $env = [ 'OMP_NUM_THREADS' => 1 ]; if ( strval( $wgImageMagickTempDir ) !== '' ) { $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; } - $cmd = - wfEscapeShellArg( $wgImageMagickConvertCommand ) . + $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + + $cmd = call_user_func_array( 'wfEscapeShellArg', array_merge( + [ $wgImageMagickConvertCommand ], + $quality, // Specify white background color, will be used for transparent images // in Internet Explorer/Windows instead of default black. - " {$quality} -background white" . - " {$decoderHint} " . - wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) . - " {$animation_pre}" . + [ '-background', 'white' ], + $decoderHint, + [ $this->escapeMagickInput( $params['srcPath'], $scene ) ], + $animation_pre, // For the -thumbnail option a "!" is needed to force exact size, // or ImageMagick may decide your ratio is wrong and slice off // a pixel. - " -thumbnail " . wfEscapeShellArg( "{$params['physicalDimensions']}!" ) . + [ '-thumbnail', "{$width}x{$height}!" ], // Add the source url as a comment to the thumb, but don't add the flag if there's no comment ( $params['comment'] !== '' - ? " -set comment " . wfEscapeShellArg( $this->escapeMagickProperty( $params['comment'] ) ) - : '' ) . - " -depth 8 $sharpen" . - " {$animation_post} " . - wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) ) . " 2>&1"; + ? [ '-set', 'comment', $this->escapeMagickProperty( $params['comment'] ) ] + : [] ), + // T108616: Avoid exposure of local file path + [ '+set', 'Thumb::URI' ], + [ '-depth', 8 ], + $sharpen, + [ '-rotate', "-$rotation" ], + $subsampling, + $animation_post, + [ $this->escapeMagickOutput( $params['dstPath'] ) ] ) ); wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); - wfProfileIn( 'convert' ); $retval = 0; - $err = wfShellExec( $cmd, $retval, $env ); - wfProfileOut( 'convert' ); + $err = wfShellExecWithStderr( $cmd, $retval, $env ); if ( $retval !== 0 ) { $this->logErrorForExternalProcess( $retval, $err, $cmd ); - return $this->getMediaTransformError( $params, $err ); + + return $this->getMediaTransformError( $params, "$err\nError code: $retval" ); } return false; # No error } + /** + * Transform an image using the Imagick PHP extension + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise + */ + protected function transformImageMagickExt( $image, $params ) { + global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea, + $wgJpegPixelFormat; + + try { + $im = new Imagick(); + $im->readImage( $params['srcPath'] ); + + if ( $params['mimeType'] == 'image/jpeg' ) { + // Sharpening, see T8193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold + ) { + // Hack, since $wgSharpenParameter is written specifically for the command line convert + list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter ); + $im->sharpenImage( $radius, $sigma ); + } + $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; + $im->setCompressionQuality( $qualityVal ?: 80 ); + if ( $params['interlace'] ) { + $im->setInterlaceScheme( Imagick::INTERLACE_JPEG ); + } + if ( $wgJpegPixelFormat ) { + $factors = $this->imageMagickSubsampling( $wgJpegPixelFormat ); + $im->setSamplingFactors( $factors ); + } + } elseif ( $params['mimeType'] == 'image/png' ) { + $im->setCompressionQuality( 95 ); + if ( $params['interlace'] ) { + $im->setInterlaceScheme( Imagick::INTERLACE_PNG ); + } + } elseif ( $params['mimeType'] == 'image/gif' ) { + if ( $this->getImageArea( $image ) > $wgMaxAnimatedGifArea ) { + // Extract initial frame only; we're so big it'll + // be a total drag. :P + $im->setImageScene( 0 ); + } elseif ( $this->isAnimatedImage( $image ) ) { + // Coalesce is needed to scale animated GIFs properly (T3017). + $im = $im->coalesceImages(); + } + // GIF interlacing is only available since 6.3.4 + $v = Imagick::getVersion(); + preg_match( '/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $v ); + + if ( $params['interlace'] && version_compare( $v[1], '6.3.4' ) >= 0 ) { + $im->setInterlaceScheme( Imagick::INTERLACE_GIF ); + } + } + + $rotation = isset( $params['disableRotation'] ) ? 0 : $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + + $im->setImageBackgroundColor( new ImagickPixel( 'white' ) ); + + // Call Imagick::thumbnailImage on each frame + foreach ( $im as $i => $frame ) { + if ( !$frame->thumbnailImage( $width, $height, /* fit */ false ) ) { + return $this->getMediaTransformError( $params, "Error scaling frame $i" ); + } + } + $im->setImageDepth( 8 ); + + if ( $rotation ) { + if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { + return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" ); + } + } + + if ( $this->isAnimatedImage( $image ) ) { + wfDebug( __METHOD__ . ": Writing animated thumbnail\n" ); + // This is broken somehow... can't find out how to fix it + $result = $im->writeImages( $params['dstPath'], true ); + } else { + $result = $im->writeImage( $params['dstPath'] ); + } + if ( !$result ) { + return $this->getMediaTransformError( $params, + "Unable to write thumbnail to {$params['dstPath']}" ); + } + } catch ( ImagickException $e ) { + return $this->getMediaTransformError( $params, $e->getMessage() ); + } + + return false; + } + /** * Transform an image using a custom command * - * @param $image File File associated with this thumbnail - * @param $params array Array with scaler params + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params * - * @return MediaTransformError Error object if error occured, false (=no error) otherwise + * @return MediaTransformError Error|bool object if error occurred, false (=no error) otherwise */ protected function transformCustom( $image, $params ) { # Use a custom convert command @@ -277,91 +378,75 @@ class BitmapHandler extends ImageHandler { $dst = wfEscapeShellArg( $params['dstPath'] ); $cmd = $wgCustomConvertCommand; $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames - $cmd = str_replace( '%h', $params['physicalHeight'], - str_replace( '%w', $params['physicalWidth'], $cmd ) ); # Size + $cmd = str_replace( '%h', wfEscapeShellArg( $params['physicalHeight'] ), + str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); - wfProfileIn( 'convert' ); $retval = 0; - $err = wfShellExec( $cmd, $retval ); - wfProfileOut( 'convert' ); + $err = wfShellExecWithStderr( $cmd, $retval ); if ( $retval !== 0 ) { $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return $this->getMediaTransformError( $params, $err ); } - return false; # No error - } - /** - * Log an error that occured in an external process - * - * @param $retval int - * @param $err int - * @param $cmd string - */ - protected function logErrorForExternalProcess( $retval, $err, $cmd ) { - wfDebugLog( 'thumbnail', - sprintf( 'thumbnail failed on %s: error %d "%s" from "%s"', - wfHostname(), $retval, trim( $err ), $cmd ) ); - } - /** - * Get a MediaTransformError with error 'thumbnail_error' - * - * @param $params array Parameter array as passed to the transform* functions - * @param $errMsg string Error message - * @return MediaTransformError - */ - protected function getMediaTransformError( $params, $errMsg ) { - return new MediaTransformError( 'thumbnail_error', $params['clientWidth'], - $params['clientHeight'], $errMsg ); + return false; # No error } /** * Transform an image using the built in GD library * - * @param $image File File associated with this thumbnail - * @param $params array Array with scaler params + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params * - * @return MediaTransformError Error object if error occured, false (=no error) otherwise + * @return MediaTransformError|bool Error object if error occurred, false (=no error) otherwise */ protected function transformGd( $image, $params ) { # Use PHP's builtin GD library functions. - # # First find out what kind of file this is, and select the correct # input routine for this. - $typemap = array( - 'image/gif' => array( 'imagecreatefromgif', 'palette', 'imagegif' ), - 'image/jpeg' => array( 'imagecreatefromjpeg', 'truecolor', array( __CLASS__, 'imageJpegWrapper' ) ), - 'image/png' => array( 'imagecreatefrompng', 'bits', 'imagepng' ), - 'image/vnd.wap.wbmp' => array( 'imagecreatefromwbmp', 'palette', 'imagewbmp' ), - 'image/xbm' => array( 'imagecreatefromxbm', 'palette', 'imagexbm' ), - ); + $typemap = [ + 'image/gif' => [ 'imagecreatefromgif', 'palette', false, 'imagegif' ], + 'image/jpeg' => [ 'imagecreatefromjpeg', 'truecolor', true, + [ __CLASS__, 'imageJpegWrapper' ] ], + 'image/png' => [ 'imagecreatefrompng', 'bits', false, 'imagepng' ], + 'image/vnd.wap.wbmp' => [ 'imagecreatefromwbmp', 'palette', false, 'imagewbmp' ], + 'image/xbm' => [ 'imagecreatefromxbm', 'palette', false, 'imagexbm' ], + ]; + if ( !isset( $typemap[$params['mimeType']] ) ) { $err = 'Image type not supported'; wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_image-type' ); + $errMsg = wfMessage( 'thumbnail_image-type' )->text(); + return $this->getMediaTransformError( $params, $errMsg ); } - list( $loader, $colorStyle, $saveType ) = $typemap[$params['mimeType']]; + list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']]; if ( !function_exists( $loader ) ) { $err = "Incomplete GD library configuration: missing function $loader"; wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_gd-library', $loader ); + $errMsg = wfMessage( 'thumbnail_gd-library', $loader )->text(); + return $this->getMediaTransformError( $params, $errMsg ); } if ( !file_exists( $params['srcPath'] ) ) { $err = "File seems to be missing: {$params['srcPath']}"; wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_image-missing', $params['srcPath'] ); + $errMsg = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text(); + return $this->getMediaTransformError( $params, $errMsg ); } $src_image = call_user_func( $loader, $params['srcPath'] ); - $dst_image = imagecreatetruecolor( $params['physicalWidth'], - $params['physicalHeight'] ); + + $rotation = function_exists( 'imagerotate' ) && !isset( $params['disableRotation'] ) ? + $this->getRotation( $image ) : + 0; + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + $dst_image = imagecreatetruecolor( $width, $height ); // Initialise the destination image to transparent instead of // the default solid black, to support PNG and GIF transparency nicely @@ -374,18 +459,29 @@ class BitmapHandler extends ImageHandler { // It may just uglify them, and completely breaks transparency. imagecopyresized( $dst_image, $src_image, 0, 0, 0, 0, - $params['physicalWidth'], $params['physicalHeight'], + $width, $height, imagesx( $src_image ), imagesy( $src_image ) ); } else { imagecopyresampled( $dst_image, $src_image, 0, 0, 0, 0, - $params['physicalWidth'], $params['physicalHeight'], + $width, $height, imagesx( $src_image ), imagesy( $src_image ) ); } + if ( $rotation % 360 != 0 && $rotation % 90 == 0 ) { + $rot_image = imagerotate( $dst_image, $rotation, 0 ); + imagedestroy( $dst_image ); + $dst_image = $rot_image; + } + imagesavealpha( $dst_image, true ); - call_user_func( $saveType, $dst_image, $params['dstPath'] ); + $funcParams = [ $dst_image, $params['dstPath'] ]; + if ( $useQuality && isset( $params['quality'] ) ) { + $funcParams[] = $params['quality']; + } + call_user_func_array( $saveType, $funcParams ); + imagedestroy( $dst_image ); imagedestroy( $src_image ); @@ -393,213 +489,100 @@ class BitmapHandler extends ImageHandler { } /** - * Escape a string for ImageMagick's property input (e.g. -set -comment) - * See InterpretImageProperties() in magick/property.c + * Callback for transformGd when transforming jpeg images. */ - function escapeMagickProperty( $s ) { - // Double the backslashes - $s = str_replace( '\\', '\\\\', $s ); - // Double the percents - $s = str_replace( '%', '%%', $s ); - // Escape initial - or @ - if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) { - $s = '\\' . $s; - } - return $s; + // FIXME: transformImageMagick() & transformImageMagickExt() uses JPEG quality 80, here it's 95? + static function imageJpegWrapper( $dst_image, $thumbPath, $quality = 95 ) { + imageinterlace( $dst_image ); + imagejpeg( $dst_image, $thumbPath, $quality ); } /** - * Escape a string for ImageMagick's input filenames. See ExpandFilenames() - * and GetPathComponent() in magick/utility.c. - * - * This won't work with an initial ~ or @, so input files should be prefixed - * with the directory name. - * - * Glob character unescaping is broken in ImageMagick before 6.6.1-5, but - * it's broken in a way that doesn't involve trying to convert every file - * in a directory, so we're better off escaping and waiting for the bugfix - * to filter down to users. + * Returns whether the current scaler supports rotation (im and gd do) * - * @param $path string The file path - * @param $scene string The scene specification, or false if there is none + * @return bool */ - function escapeMagickInput( $path, $scene = false ) { - # Die on initial metacharacters (caller should prepend path) - $firstChar = substr( $path, 0, 1 ); - if ( $firstChar === '~' || $firstChar === '@' ) { - throw new MWException( __METHOD__ . ': cannot escape this path name' ); + public function canRotate() { + $scaler = $this->getScalerType( null, false ); + switch ( $scaler ) { + case 'im': + # ImageMagick supports autorotation + return true; + case 'imext': + # Imagick::rotateImage + return true; + case 'gd': + # GD's imagerotate function is used to rotate images, but not + # all precompiled PHP versions have that function + return function_exists( 'imagerotate' ); + default: + # Other scalers don't support rotation + return false; } - - # Escape glob chars - $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path ); - - return $this->escapeMagickPath( $path, $scene ); } /** - * Escape a string for ImageMagick's output filename. See - * InterpretImageFilename() in magick/image.c. + * @see $wgEnableAutoRotation + * @return bool Whether auto rotation is enabled */ - function escapeMagickOutput( $path, $scene = false ) { - $path = str_replace( '%', '%%', $path ); - return $this->escapeMagickPath( $path, $scene ); - } + public function autoRotateEnabled() { + global $wgEnableAutoRotation; - /** - * Armour a string against ImageMagick's GetPathComponent(). This is a - * helper function for escapeMagickInput() and escapeMagickOutput(). - * - * @param $path string The file path - * @param $scene string The scene specification, or false if there is none - */ - protected function escapeMagickPath( $path, $scene = false ) { - # Die on format specifiers (other than drive letters). The regex is - # meant to match all the formats you get from "convert -list format" - if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) { - if ( wfIsWindows() && is_dir( $m[0] ) ) { - // OK, it's a drive letter - // ImageMagick has a similar exception, see IsMagickConflict() - } else { - throw new MWException( __METHOD__ . ': unexpected colon character in path name' ); - } + if ( $wgEnableAutoRotation === null ) { + // Only enable auto-rotation when we actually can + return $this->canRotate(); } - # If there are square brackets, add a do-nothing scene specification - # to force a literal interpretation - if ( $scene === false ) { - if ( strpos( $path, '[' ) !== false ) { - $path .= '[0--1]'; - } - } else { - $path .= "[$scene]"; - } - return $path; + return $wgEnableAutoRotation; } /** - * Retrieve the version of the installed ImageMagick - * You can use PHPs version_compare() to use this value - * Value is cached for one hour. - * @return String representing the IM version. + * @param File $file + * @param array $params Rotate parameters. + * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 + * @since 1.21 + * @return bool|MediaTransformError */ - protected function getMagickVersion() { - global $wgMemc; - - $cache = $wgMemc->get( "imagemagick-version" ); - if ( !$cache ) { - global $wgImageMagickConvertCommand; - $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . ' -version'; - wfDebug( __METHOD__ . ": Running convert -version\n" ); - $retval = ''; - $return = wfShellExec( $cmd, $retval ); - $x = preg_match( '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches ); - if ( $x != 1 ) { - wfDebug( __METHOD__ . ": ImageMagick version check failed\n" ); - return null; - } - $wgMemc->set( "imagemagick-version", $matches[1], 3600 ); - return $matches[1]; - } - return $cache; - } - - static function imageJpegWrapper( $dst_image, $thumbPath ) { - imageinterlace( $dst_image ); - imagejpeg( $dst_image, $thumbPath, 95 ); - } + public function rotate( $file, $params ) { + global $wgImageMagickConvertCommand; + $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; + $scene = false; - function getMetadata( $image, $filename ) { - global $wgShowEXIF; - if ( $wgShowEXIF && file_exists( $filename ) ) { - $exif = new Exif( $filename ); - $data = $exif->getFilteredData(); - if ( $data ) { - $data['MEDIAWIKI_EXIF_VERSION'] = Exif::version(); - return serialize( $data ); - } else { - return '0'; - } - } else { - return ''; - } - } - - function getMetadataType( $image ) { - return 'exif'; - } - - function isMetadataValid( $image, $metadata ) { - global $wgShowEXIF; - if ( !$wgShowEXIF ) { - # Metadata disabled and so an empty field is expected - return true; - } - if ( $metadata === '0' ) { - # Special value indicating that there is no EXIF data in the file - return true; - } - wfSuppressWarnings(); - $exif = unserialize( $metadata ); - wfRestoreWarnings(); - if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) || - $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() ) - { - # Wrong version - wfDebug( __METHOD__ . ": wrong version\n" ); - return false; - } - return true; - } + $scaler = $this->getScalerType( null, false ); + switch ( $scaler ) { + case 'im': + $cmd = wfEscapeShellArg( $wgImageMagickConvertCommand ) . " " . + wfEscapeShellArg( $this->escapeMagickInput( $params['srcPath'], $scene ) ) . + " -rotate " . wfEscapeShellArg( "-$rotation" ) . " " . + wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) ); + wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); + } - /** - * Get a list of EXIF metadata items which should be displayed when - * the metadata table is collapsed. - * - * @return array of strings - * @access private - */ - function visibleMetadataFields() { - $fields = array(); - $lines = explode( "\n", wfMsgForContent( 'metadata-fields' ) ); - foreach ( $lines as $line ) { - $matches = array(); - if ( preg_match( '/^\\*\s*(.*?)\s*$/', $line, $matches ) ) { - $fields[] = $matches[1]; - } - } - $fields = array_map( 'strtolower', $fields ); - return $fields; - } + return false; + case 'imext': + $im = new Imagick(); + $im->readImage( $params['srcPath'] ); + if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { + return new MediaTransformError( 'thumbnail_error', 0, 0, + "Error rotating $rotation degrees" ); + } + $result = $im->writeImage( $params['dstPath'] ); + if ( !$result ) { + return new MediaTransformError( 'thumbnail_error', 0, 0, + "Unable to write image to {$params['dstPath']}" ); + } - function formatMetadata( $image ) { - $result = array( - 'visible' => array(), - 'collapsed' => array() - ); - $metadata = $image->getMetadata(); - if ( !$metadata ) { - return false; - } - $exif = unserialize( $metadata ); - if ( !$exif ) { - return false; - } - unset( $exif['MEDIAWIKI_EXIF_VERSION'] ); - $format = new FormatExif( $exif ); - - $formatted = $format->getFormattedData(); - // Sort fields into visible and collapsed - $visibleFields = $this->visibleMetadataFields(); - foreach ( $formatted as $name => $value ) { - $tag = strtolower( $name ); - self::addMeta( $result, - in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed', - 'exif', - $tag, - $value - ); + return false; + default: + return new MediaTransformError( 'thumbnail_error', 0, 0, + "$scaler rotation not implemented" ); } - return $result; } }