X-Git-Url: http://git.heureux-cyclage.org/?a=blobdiff_plain;f=includes%2Fmedia%2FBitmap.php;h=878ad58fb90649a48ac96915eb24ef1053946017;hb=d93ece35276f711f26d680df207155e62b7946ac;hp=d004659b76c2497a8382b421ea7f784d21e1152c;hpb=9686c4bf20ac1ba55a2da64e9515dfc09b835e7a;p=lhc%2Fweb%2Fwiklou.git diff --git a/includes/media/Bitmap.php b/includes/media/Bitmap.php index d004659b76..878ad58fb9 100644 --- a/includes/media/Bitmap.php +++ b/includes/media/Bitmap.php @@ -1,85 +1,226 @@ getMimeType(); + # Obtain the source, pre-rotation dimensions $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() ) + # due to requested size being bigger than source. + 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. - if ( $mimeType !== 'image/jpeg' && - $srcWidth * $srcHeight > $wgMaxImageArea ) - { - return false; + # Check if the file is smaller than the maximum image area for thumbnailing + $checkImageAreaHookResult = null; + wfRunHooks( 'BitmapHandlerCheckImageArea', array( $image, &$params, &$checkImageAreaHookResult ) ); + if ( is_null( $checkImageAreaHookResult ) ) { + global $wgMaxImageArea; + + if ( $srcWidth * $srcHeight > $wgMaxImageArea && + !( $image->getMimeType() == 'image/jpeg' && + self::getScalerType( false, false ) == 'im' ) ) { + # Only ImageMagick can efficiently downsize jpg images without loading + # the entire file in memory + return false; + } + } else { + return $checkImageAreaHookResult; } 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; + + + /** + * Extracts the width/height if the image will be scaled before rotating + * + * This will match the physical size/aspect ratio of the original image + * prior to application of the rotation -- so for a portrait image that's + * stored as raw landscape with 90-degress rotation, the resulting size + * will be wider than it is tall. + * + * @param $params array Parameters as returned by normaliseParams + * @param $rotation int The rotation angle that will be applied + * @return array ($width, $height) array + */ + public function extractPreRotationDimensions( $params, $rotation ) { + if ( $rotation == 90 || $rotation == 270 ) { + # We'll resize before rotation, so swap the dimensions again + $width = $params['physicalHeight']; + $height = $params['physicalWidth']; + } else { + $width = $params['physicalWidth']; + $height = $params['physicalHeight']; + } + return array( $width, $height ); } - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - global $wgUseImageMagick, $wgImageMagickConvertCommand, $wgImageMagickTempDir; - global $wgCustomConvertCommand, $wgUseImageResize; - global $wgSharpenParameter, $wgSharpenReductionThreshold; - global $wgMaxAnimatedGifArea; + /** + * Function that returns the number of pixels to be thumbnailed. + * Intended for animated GIFs to multiply by the number of frames. + * + * @param File $image + * @return int + */ + function getImageArea( $image ) { + return $image->getWidth() * $image->getHeight(); + } + + /** + * @param $image File + * @param $dstPath + * @param $dstUrl + * @param $params + * @param int $flags + * @return MediaTransformError|ThumbnailImage|TransformParameterError + */ + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { if ( !$this->normaliseParams( $image, $params ) ) { return new TransformParameterError( $params ); } - $physicalWidth = $params['physicalWidth']; - $physicalHeight = $params['physicalHeight']; - $clientWidth = $params['width']; - $clientHeight = $params['height']; - $descriptionUrl = isset( $params['descriptionUrl'] ) ? "File source: ". $params['descriptionUrl'] : ''; - $srcWidth = $image->getWidth(); - $srcHeight = $image->getHeight(); - $mimeType = $image->getMimeType(); - $srcPath = $image->getPath(); - $retval = 0; - wfDebug( __METHOD__.": creating {$physicalWidth}x{$physicalHeight} thumbnail at $dstPath\n" ); + # 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->getLocalRefPath(), + 'dstPath' => $dstPath, + 'dstUrl' => $dstUrl, + ); + + # Determine scaler type + $scaler = self::getScalerType( $dstPath ); + + wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} thumbnail at $dstPath using scaler $scaler\n" ); + + if ( !$image->mustRender() && + $scalerParams['physicalWidth'] == $scalerParams['srcWidth'] + && $scalerParams['physicalHeight'] == $scalerParams['srcHeight'] ) { - if ( !$image->mustRender() && $physicalWidth == $srcWidth && $physicalHeight == $srcHeight ) { # normaliseParams (or the user) wants us to return the unscaled image - wfDebug( __METHOD__.": returning unscaled image\n" ); - return new ThumbnailImage( $image, $image->getURL(), $clientWidth, $clientHeight, $srcPath ); + wfDebug( __METHOD__ . ": returning unscaled image\n" ); + return $this->getClientScalingThumbnailImage( $image, $scalerParams ); } - if ( !$dstPath ) { - // No output path available, client side scaling only + + 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 ); + } + + if ( $flags & self::TRANSFORM_LATER ) { + wfDebug( __METHOD__ . ": Transforming later per flags.\n" ); + return new ThumbnailImage( $image, $dstUrl, $scalerParams['clientWidth'], + $scalerParams['clientHeight'], $dstPath ); + } + + # Try to make a target path for the thumbnail + if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { + wfDebug( __METHOD__ . ": Unable to create thumbnail destination directory, falling back to client scaling\n" ); + return $this->getClientScalingThumbnailImage( $image, $scalerParams ); + } + + # Try a hook + $mto = null; + wfRunHooks( 'BitmapHandlerTransform', array( $this, $image, &$scalerParams, &$mto ) ); + if ( !is_null( $mto ) ) { + wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto\n" ); + $scaler = 'hookaborted'; + } + + switch ( $scaler ) { + case 'hookaborted': + # Handled by the hook above + $err = $mto->isError() ? $mto : false; + break; + case 'im': + $err = $this->transformImageMagick( $image, $scalerParams ); + break; + case 'custom': + $err = $this->transformCustom( $image, $scalerParams ); + break; + case 'imext': + $err = $this->transformImageMagickExt( $image, $scalerParams ); + break; + case 'gd': + default: + $err = $this->transformGd( $image, $scalerParams ); + break; + } + + # 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'] ); + } elseif ( $mto ) { + return $mto; + } else { + return new ThumbnailImage( $image, $dstUrl, $scalerParams['clientWidth'], + $scalerParams['clientHeight'], $dstPath ); + } + } + + /** + * Returns which scaler type should be used. Creates parent directories + * for $dstPath and returns 'client' on error + * + * @return string client,im,custom,gd + */ + protected static function getScalerType( $dstPath, $checkDstPath = true ) { + global $wgUseImageResize, $wgUseImageMagick, $wgCustomConvertCommand; + + if ( !$dstPath && $checkDstPath ) { + # No output path available, client side scaling only $scaler = 'client'; - } elseif( !$wgUseImageResize ) { + } elseif ( !$wgUseImageResize ) { $scaler = 'client'; } elseif ( $wgUseImageMagick ) { $scaler = 'im'; @@ -87,265 +228,512 @@ 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" ); + return $scaler; + } - if ( $scaler == 'client' ) { - # Client-side image scaling, use the source URL - # Using the destination URL in a TRANSFORM_LATER request would be incorrect - return new ThumbnailImage( $image, $image->getURL(), $clientWidth, $clientHeight, $srcPath ); + /** + * Get a ThumbnailImage that respresents an image that will be scaled + * client side + * + * @param $image File File associated with this thumbnail + * @param $params array Array with scaler params + * @return ThumbnailImage + * + * @fixme no rotation support + */ + protected function getClientScalingThumbnailImage( $image, $params ) { + return new ThumbnailImage( $image, $image->getURL(), + $params['clientWidth'], $params['clientHeight'], $params['srcPath'] ); + } + + /** + * Transform an image using ImageMagick + * + * @param $image File File associated with this thumbnail + * @param $params array Array with scaler params + * + * @return MediaTransformError Error object if error occured, false (=no error) otherwise + */ + protected function transformImageMagick( $image, $params ) { + # use ImageMagick + global $wgSharpenReductionThreshold, $wgSharpenParameter, + $wgMaxAnimatedGifArea, + $wgImageMagickTempDir, $wgImageMagickConvertCommand; + + $quality = ''; + $sharpen = ''; + $scene = false; + $animation_pre = ''; + $animation_post = ''; + $decoderHint = ''; + if ( $params['mimeType'] == 'image/jpeg' ) { + $quality = "-quality 80"; // 80% + # Sharpening, see bug 6193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold ) { + $sharpen = "-sharpen " . wfEscapeShellArg( $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']}"; + } + + } elseif ( $params['mimeType'] == 'image/png' ) { + $quality = "-quality 95"; // zlib 9, adaptive filtering + + } 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 + $scene = 0; + + } elseif ( $this->isAnimatedImage( $image ) ) { + // Coalesce is needed to scale animated GIFs properly (bug 1017). + $animation_pre = '-coalesce'; + // We optimize the output, but -optimize is broken, + // use optimizeTransparency instead (bug 11822) + if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { + $animation_post = '-fuzz 5% -layers optimizeTransparency'; + } + } + } elseif ( $params['mimeType'] == 'image/x-xcf' ) { + $animation_post = '-layers merge'; } - if ( $flags & self::TRANSFORM_LATER ) { - wfDebug( __METHOD__.": Transforming later per flags.\n" ); - return new ThumbnailImage( $image, $dstUrl, $clientWidth, $clientHeight, $dstPath ); + // Use one thread only, to avoid deadlock bugs on OOM + $env = array( 'OMP_NUM_THREADS' => 1 ); + if ( strval( $wgImageMagickTempDir ) !== '' ) { + $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; } - if ( !wfMkdirParents( dirname( $dstPath ) ) ) { - wfDebug( __METHOD__.": Unable to create thumbnail destination directory, falling back to client scaling\n" ); - return new ThumbnailImage( $image, $image->getURL(), $clientWidth, $clientHeight, $srcPath ); + $rotation = $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); + + $cmd = + wfEscapeShellArg( $wgImageMagickConvertCommand ) . + // 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}" . + // 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( "{$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 " . + " -rotate -$rotation " . + " {$animation_post} " . + wfEscapeShellArg( $this->escapeMagickOutput( $params['dstPath'] ) ) . " 2>&1"; + + wfDebug( __METHOD__ . ": running ImageMagick: $cmd\n" ); + wfProfileIn( 'convert' ); + $retval = 0; + $err = wfShellExec( $cmd, $retval, $env ); + wfProfileOut( 'convert' ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return $this->getMediaTransformError( $params, $err ); } - if ( $scaler == 'im' ) { - # use ImageMagick - - $quality = ''; - $sharpen = ''; - $frame = ''; - $animation = ''; - if ( $mimeType == 'image/jpeg' ) { - $quality = "-quality 80"; // 80% - # Sharpening, see bug 6193 - if ( ( $physicalWidth + $physicalHeight ) / ( $srcWidth + $srcHeight ) < $wgSharpenReductionThreshold ) { - $sharpen = "-sharpen " . wfEscapeShellArg( $wgSharpenParameter ); + return false; # No error + } + + /** + * Transform an image using the Imagick PHP extension + * + * @param $image File File associated with this thumbnail + * @param $params array Array with scaler params + * + * @return MediaTransformError Error object if error occured, false (=no error) otherwise + */ + protected function transformImageMagickExt( $image, $params ) { + global $wgSharpenReductionThreshold, $wgSharpenParameter, $wgMaxAnimatedGifArea; + + try { + $im = new Imagick(); + $im->readImage( $params['srcPath'] ); + + if ( $params['mimeType'] == 'image/jpeg' ) { + // Sharpening, see bug 6193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $params['srcWidth'] + $params['srcHeight'] ) + < $wgSharpenReductionThreshold ) { + // Hack, since $wgSharpenParamater is written specifically for the command line convert + list( $radius, $sigma ) = explode( 'x', $wgSharpenParameter ); + $im->sharpenImage( $radius, $sigma ); } - } elseif ( $mimeType == 'image/png' ) { - $quality = "-quality 95"; // zlib 9, adaptive filtering - } elseif( $mimeType == 'image/gif' ) { - if( $this->getImageArea( $image, $srcWidth, $srcHeight ) > $wgMaxAnimatedGifArea ) { + $im->setCompressionQuality( 80 ); + } elseif( $params['mimeType'] == 'image/png' ) { + $im->setCompressionQuality( 95 ); + } 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 - $frame = '[0]'; - } else { + $im->setImageScene( 0 ); + } elseif ( $this->isAnimatedImage( $image ) ) { // Coalesce is needed to scale animated GIFs properly (bug 1017). - $animation = ' -coalesce '; + $im = $im->coalesceImages(); } } - if ( strval( $wgImageMagickTempDir ) !== '' ) { - $tempEnv = 'MAGICK_TMPDIR=' . wfEscapeShellArg( $wgImageMagickTempDir ) . ' '; - } else { - $tempEnv = ''; - } + $rotation = $this->getRotation( $image ); + list( $width, $height ) = $this->extractPreRotationDimensions( $params, $rotation ); - # Specify white background color, will be used for transparent images - # in Internet Explorer/Windows instead of default black. - - # Note, we specify "-size {$physicalWidth}" and NOT "-size {$physicalWidth}x{$physicalHeight}". - # It seems that ImageMagick has a bug wherein it produces thumbnails of - # the wrong size in the second case. - - $cmd = - $tempEnv . - wfEscapeShellArg($wgImageMagickConvertCommand) . - " {$quality} -background white -size {$physicalWidth} ". - wfEscapeShellArg($srcPath . $frame) . - $animation . - // For the -resize option a "!" is needed to force exact size, - // or ImageMagick may decide your ratio is wrong and slice off - // a pixel. - " -thumbnail " . wfEscapeShellArg( "{$physicalWidth}x{$physicalHeight}!" ) . - " -set comment " . wfEscapeShellArg( "{$descriptionUrl}" ) . - " -depth 8 $sharpen " . - wfEscapeShellArg($dstPath) . " 2>&1"; - wfDebug( __METHOD__.": running ImageMagick: $cmd\n"); - wfProfileIn( 'convert' ); - $err = wfShellExec( $cmd, $retval ); - wfProfileOut( 'convert' ); - } elseif( $scaler == 'custom' ) { - # Use a custom convert command - # Variables: %s %d %w %h - $src = wfEscapeShellArg( $srcPath ); - $dst = wfEscapeShellArg( $dstPath ); - $cmd = $wgCustomConvertCommand; - $cmd = str_replace( '%s', $src, str_replace( '%d', $dst, $cmd ) ); # Filenames - $cmd = str_replace( '%h', $physicalHeight, str_replace( '%w', $physicalWidth, $cmd ) ); # Size - wfDebug( __METHOD__.": Running custom convert command $cmd\n" ); - wfProfileIn( 'convert' ); - $err = wfShellExec( $cmd, $retval ); - wfProfileOut( 'convert' ); - } else /* $scaler == 'gd' */ { - # 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' ), - ); - if( !isset( $typemap[$mimeType] ) ) { - $err = 'Image type not supported'; - wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_image-type' ); - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $errMsg ); - } - list( $loader, $colorStyle, $saveType ) = $typemap[$mimeType]; + $im->setImageBackgroundColor( new ImagickPixel( 'white' ) ); - if( !function_exists( $loader ) ) { - $err = "Incomplete GD library configuration: missing function $loader"; - wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_gd-library', $loader ); - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $errMsg ); + // 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 ( !file_exists( $srcPath ) ) { - $err = "File seems to be missing: $srcPath"; - wfDebug( "$err\n" ); - $errMsg = wfMsg ( 'thumbnail_image-missing', $srcPath ); - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $errMsg ); + if ( $rotation ) { + if ( !$im->rotateImage( new ImagickPixel( 'white' ), 360 - $rotation ) ) { + return $this->getMediaTransformError( $params, "Error rotating $rotation degrees" ); + } } - $src_image = call_user_func( $loader, $srcPath ); - $dst_image = imagecreatetruecolor( $physicalWidth, $physicalHeight ); - - // Initialise the destination image to transparent instead of - // the default solid black, to support PNG and GIF transparency nicely - $background = imagecolorallocate( $dst_image, 0, 0, 0 ); - imagecolortransparent( $dst_image, $background ); - imagealphablending( $dst_image, false ); - - if( $colorStyle == 'palette' ) { - // Don't resample for paletted GIF images. - // It may just uglify them, and completely breaks transparency. - imagecopyresized( $dst_image, $src_image, - 0,0,0,0, - $physicalWidth, $physicalHeight, imagesx( $src_image ), imagesy( $src_image ) ); + 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 { - imagecopyresampled( $dst_image, $src_image, - 0,0,0,0, - $physicalWidth, $physicalHeight, imagesx( $src_image ), imagesy( $src_image ) ); + $result = $im->writeImage( $params['dstPath'] ); + } + if ( !$result ) { + return $this->getMediaTransformError( $params, + "Unable to write thumbnail to {$params['dstPath']}" ); } - imagesavealpha( $dst_image, true ); - - call_user_func( $saveType, $dst_image, $dstPath ); - imagedestroy( $dst_image ); - imagedestroy( $src_image ); - $retval = 0; + } catch ( ImagickException $e ) { + return $this->getMediaTransformError( $params, $e->getMessage() ); } - $removed = $this->removeBadFile( $dstPath, $retval ); - if ( $retval != 0 || $removed ) { - wfDebugLog( 'thumbnail', - sprintf( 'thumbnail failed on %s: error %d "%s" from "%s"', - wfHostname(), $retval, trim($err), $cmd ) ); - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err ); - } else { - return new ThumbnailImage( $image, $dstUrl, $clientWidth, $clientHeight, $dstPath ); + return false; + + } + + /** + * Transform an image using a custom command + * + * @param $image File File associated with this thumbnail + * @param $params array Array with scaler params + * + * @return MediaTransformError Error object if error occured, false (=no error) otherwise + */ + protected function transformCustom( $image, $params ) { + # Use a custom convert command + global $wgCustomConvertCommand; + + # Variables: %s %d %w %h + $src = wfEscapeShellArg( $params['srcPath'] ); + $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 + wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); + wfProfileIn( 'convert' ); + $retval = 0; + $err = wfShellExec( $cmd, $retval ); + wfProfileOut( 'convert' ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return $this->getMediaTransformError( $params, $err ); } + return false; # No error } - static function imageJpegWrapper( $dst_image, $thumbPath ) { - imageinterlace( $dst_image ); - imagejpeg( $dst_image, $thumbPath, 95 ); + /** + * 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 + */ + public function getMediaTransformError( $params, $errMsg ) { + return new MediaTransformError( 'thumbnail_error', $params['clientWidth'], + $params['clientHeight'], $errMsg ); } + /** + * Transform an image using the built in GD library + * + * @param $image File File associated with this thumbnail + * @param $params array Array with scaler params + * + * @return MediaTransformError Error object if error occured, 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' ), + ); + if ( !isset( $typemap[$params['mimeType']] ) ) { + $err = 'Image type not supported'; + wfDebug( "$err\n" ); + $errMsg = wfMsg ( 'thumbnail_image-type' ); + return $this->getMediaTransformError( $params, $errMsg ); + } + list( $loader, $colorStyle, $saveType ) = $typemap[$params['mimeType']]; - 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'; - } + if ( !function_exists( $loader ) ) { + $err = "Incomplete GD library configuration: missing function $loader"; + wfDebug( "$err\n" ); + $errMsg = wfMsg ( 'thumbnail_gd-library', $loader ); + 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'] ); + return $this->getMediaTransformError( $params, $errMsg ); + } + + $src_image = call_user_func( $loader, $params['srcPath'] ); + + $rotation = function_exists( 'imagerotate' ) ? $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 + $background = imagecolorallocate( $dst_image, 0, 0, 0 ); + imagecolortransparent( $dst_image, $background ); + imagealphablending( $dst_image, false ); + + if ( $colorStyle == 'palette' ) { + // Don't resample for paletted GIF images. + // It may just uglify them, and completely breaks transparency. + imagecopyresized( $dst_image, $src_image, + 0, 0, 0, 0, + $width, $height, + imagesx( $src_image ), imagesy( $src_image ) ); } else { - return ''; + imagecopyresampled( $dst_image, $src_image, + 0, 0, 0, 0, + $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; } - } - function getMetadataType( $image ) { - return 'exif'; + imagesavealpha( $dst_image, true ); + + call_user_func( $saveType, $dst_image, $params['dstPath'] ); + imagedestroy( $dst_image ); + imagedestroy( $src_image ); + + return false; # No error } - 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; + /** + * Escape a string for ImageMagick's property input (e.g. -set -comment) + * See InterpretImageProperties() in magick/property.c + */ + 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; } - $exif = @unserialize( $metadata ); - if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) || - $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() ) - { - # Wrong version - wfDebug( __METHOD__.": wrong version\n" ); - return false; + return $s; + } + + /** + * 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. + * + * @param $path string The file path + * @param $scene string The scene specification, or false if there is none + */ + 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' ); } - return true; + + # 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. + */ + function escapeMagickOutput( $path, $scene = false ) { + $path = str_replace( '%', '%%', $path ); + return $this->escapeMagickPath( $path, $scene ); } /** - * Get a list of EXIF metadata items which should be displayed when - * the metadata table is collapsed. + * Armour a string against ImageMagick's GetPathComponent(). This is a + * helper function for escapeMagickInput() and escapeMagickOutput(). * - * @return array of strings - * @access private + * @param $path string The file path + * @param $scene string The scene specification, or false if there is none */ - 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]; + 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' ); } } - $fields = array_map( 'strtolower', $fields ); - return $fields; - } - function formatMetadata( $image ) { - $result = array( - 'visible' => array(), - 'collapsed' => array() - ); - $metadata = $image->getMetadata(); - if ( !$metadata ) { - return false; + # 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]"; } - $exif = unserialize( $metadata ); - if ( !$exif ) { - return false; + return $path; + } + + /** + * 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. + */ + 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]; } - 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 $cache; + } + + static function imageJpegWrapper( $dst_image, $thumbPath ) { + imageinterlace( $dst_image ); + imagejpeg( $dst_image, $thumbPath, 95 ); + } + + /** + * On supporting image formats, try to read out the low-level orientation + * of the file and return the angle that the file needs to be rotated to + * be viewed. + * + * This information is only useful when manipulating the original file; + * the width and height we normally work with is logical, and will match + * any produced output views. + * + * The base BitmapHandler doesn't understand any metadata formats, so this + * is left up to child classes to implement. + * + * @param $file File + * @return int 0, 90, 180 or 270 + */ + public function getRotation( $file ) { + return 0; + } + + /** + * Returns whether the current scaler supports rotation (im and gd do) + * + * @return bool + */ + public static function canRotate() { + $scaler = self::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; } - return $result; + } + + /** + * Rerurns whether the file needs to be rendered. Returns true if the + * file requires rotation and we are able to rotate it. + * + * @param $file File + * @return bool + */ + public function mustRender( $file ) { + return self::canRotate() && $this->getRotation( $file ) != 0; } }