From: Timo Tijhof Date: Sat, 28 Apr 2018 01:05:23 +0000 (+0100) Subject: media: Rename files to match the name of the class they define X-Git-Tag: 1.34.0-rc.0~5578^2 X-Git-Url: http://git.cyclocoop.org/%22%20.%20generer_url_aide%28?a=commitdiff_plain;h=9bf3916322494b688eecb87e445111c3e832e0a3;p=lhc%2Fweb%2Fwiklou.git media: Rename files to match the name of the class they define This will make jumping from file to file much easier in text editors when file name lookups will autocomplete naturally when looking for a class by name. Change-Id: I4b2e55a2e72674b619d5a592866c8a019a2b0224 --- diff --git a/autoload.php b/autoload.php index 12958ca089..b832863564 100644 --- a/autoload.php +++ b/autoload.php @@ -201,15 +201,15 @@ $wgAutoloadLocalClasses = [ 'BenchmarkSanitizer' => __DIR__ . '/maintenance/benchmarks/benchmarkSanitizer.php', 'BenchmarkTidy' => __DIR__ . '/maintenance/benchmarks/benchmarkTidy.php', 'Benchmarker' => __DIR__ . '/maintenance/benchmarks/Benchmarker.php', - 'BitmapHandler' => __DIR__ . '/includes/media/Bitmap.php', - 'BitmapHandler_ClientOnly' => __DIR__ . '/includes/media/Bitmap_ClientOnly.php', + 'BitmapHandler' => __DIR__ . '/includes/media/BitmapHandler.php', + 'BitmapHandler_ClientOnly' => __DIR__ . '/includes/media/BitmapHandler_ClientOnly.php', 'BitmapMetadataHandler' => __DIR__ . '/includes/media/BitmapMetadataHandler.php', 'Blob' => __DIR__ . '/includes/libs/rdbms/encasing/Blob.php', 'Block' => __DIR__ . '/includes/Block.php', 'BlockLevelPass' => __DIR__ . '/includes/parser/BlockLevelPass.php', 'BlockListPager' => __DIR__ . '/includes/specials/pagers/BlockListPager.php', 'BlockLogFormatter' => __DIR__ . '/includes/logging/BlockLogFormatter.php', - 'BmpHandler' => __DIR__ . '/includes/media/BMP.php', + 'BmpHandler' => __DIR__ . '/includes/media/BmpHandler.php', 'BotPassword' => __DIR__ . '/includes/user/BotPassword.php', 'BrokenRedirectsPage' => __DIR__ . '/includes/specials/SpecialBrokenRedirects.php', 'BufferingStatsdDataFactory' => __DIR__ . '/includes/libs/stats/BufferingStatsdDataFactory.php', @@ -396,7 +396,7 @@ $wgAutoloadLocalClasses = [ 'DiffOpDelete' => __DIR__ . '/includes/diff/DairikiDiff.php', 'DifferenceEngine' => __DIR__ . '/includes/diff/DifferenceEngine.php', 'Digit2Html' => __DIR__ . '/maintenance/language/digit2html.php', - 'DjVuHandler' => __DIR__ . '/includes/media/DjVu.php', + 'DjVuHandler' => __DIR__ . '/includes/media/DjVuHandler.php', 'DjVuImage' => __DIR__ . '/includes/media/DjVuImage.php', 'DnsSrvDiscoverer' => __DIR__ . '/includes/libs/DnsSrvDiscoverer.php', 'DoubleRedirectJob' => __DIR__ . '/includes/jobqueue/jobs/DoubleRedirectJob.php', @@ -452,7 +452,7 @@ $wgAutoloadLocalClasses = [ 'EventRelayerNull' => __DIR__ . '/includes/libs/eventrelayer/EventRelayerNull.php', 'ExecutableFinder' => __DIR__ . '/includes/utils/ExecutableFinder.php', 'Exif' => __DIR__ . '/includes/media/Exif.php', - 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmap.php', + 'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmapHandler.php', 'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php', 'ExportProgressFilter' => __DIR__ . '/includes/export/ExportProgressFilter.php', 'ExportSites' => __DIR__ . '/maintenance/exportSites.php', @@ -537,7 +537,7 @@ $wgAutoloadLocalClasses = [ 'FormatMetadata' => __DIR__ . '/includes/media/FormatMetadata.php', 'FormattedRCFeed' => __DIR__ . '/includes/rcfeed/FormattedRCFeed.php', 'FormlessAction' => __DIR__ . '/includes/actions/FormlessAction.php', - 'GIFHandler' => __DIR__ . '/includes/media/GIF.php', + 'GIFHandler' => __DIR__ . '/includes/media/GIFHandler.php', 'GIFMetadataExtractor' => __DIR__ . '/includes/media/GIFMetadataExtractor.php', 'GanConverter' => __DIR__ . '/languages/classes/LanguageGan.php', 'GenderCache' => __DIR__ . '/includes/cache/GenderCache.php', @@ -699,7 +699,7 @@ $wgAutoloadLocalClasses = [ 'JobQueueSecondTestQueue' => __DIR__ . '/includes/jobqueue/JobQueueSecondTestQueue.php', 'JobRunner' => __DIR__ . '/includes/jobqueue/JobRunner.php', 'JobSpecification' => __DIR__ . '/includes/jobqueue/JobSpecification.php', - 'JpegHandler' => __DIR__ . '/includes/media/Jpeg.php', + 'JpegHandler' => __DIR__ . '/includes/media/JpegHandler.php', 'JpegMetadataExtractor' => __DIR__ . '/includes/media/JpegMetadataExtractor.php', 'JsonContent' => __DIR__ . '/includes/content/JsonContent.php', 'JsonContentHandler' => __DIR__ . '/includes/content/JsonContentHandler.php', @@ -1098,7 +1098,7 @@ $wgAutoloadLocalClasses = [ 'Orphans' => __DIR__ . '/maintenance/orphans.php', 'OutputPage' => __DIR__ . '/includes/OutputPage.php', 'PHPVersionCheck' => __DIR__ . '/includes/PHPVersionCheck.php', - 'PNGHandler' => __DIR__ . '/includes/media/PNG.php', + 'PNGHandler' => __DIR__ . '/includes/media/PNGHandler.php', 'PNGMetadataExtractor' => __DIR__ . '/includes/media/PNGMetadataExtractor.php', 'PPCustomFrame_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php', 'PPCustomFrame_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php', @@ -1506,7 +1506,7 @@ $wgAutoloadLocalClasses = [ 'StubUserLang' => __DIR__ . '/includes/StubObject.php', 'SubmitAction' => __DIR__ . '/includes/actions/SubmitAction.php', 'SubpageImportTitleFactory' => __DIR__ . '/includes/title/SubpageImportTitleFactory.php', - 'SvgHandler' => __DIR__ . '/includes/media/SVG.php', + 'SvgHandler' => __DIR__ . '/includes/media/SvgHandler.php', 'SwiftFileBackend' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', 'SwiftFileBackendDirList' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', 'SwiftFileBackendFileList' => __DIR__ . '/includes/libs/filebackend/SwiftFileBackend.php', @@ -1532,7 +1532,7 @@ $wgAutoloadLocalClasses = [ 'ThumbnailImage' => __DIR__ . '/includes/media/MediaTransformOutput.php', 'ThumbnailRenderJob' => __DIR__ . '/includes/jobqueue/jobs/ThumbnailRenderJob.php', 'TidyUpBug37714' => __DIR__ . '/maintenance/tidyUpBug37714.php', - 'TiffHandler' => __DIR__ . '/includes/media/Tiff.php', + 'TiffHandler' => __DIR__ . '/includes/media/TiffHandler.php', 'Timing' => __DIR__ . '/includes/libs/Timing.php', 'Title' => __DIR__ . '/includes/Title.php', 'TitleArray' => __DIR__ . '/includes/TitleArray.php', @@ -1662,7 +1662,7 @@ $wgAutoloadLocalClasses = [ 'WebInstallerUpgrade' => __DIR__ . '/includes/installer/WebInstallerUpgrade.php', 'WebInstallerUpgradeDoc' => __DIR__ . '/includes/installer/WebInstallerUpgradeDoc.php', 'WebInstallerWelcome' => __DIR__ . '/includes/installer/WebInstallerWelcome.php', - 'WebPHandler' => __DIR__ . '/includes/media/WebP.php', + 'WebPHandler' => __DIR__ . '/includes/media/WebPHandler.php', 'WebRequest' => __DIR__ . '/includes/WebRequest.php', 'WebRequestUpload' => __DIR__ . '/includes/WebRequestUpload.php', 'WebResponse' => __DIR__ . '/includes/WebResponse.php', diff --git a/includes/media/BMP.php b/includes/media/BMP.php deleted file mode 100644 index 0229ac11b7..0000000000 --- a/includes/media/BMP.php +++ /dev/null @@ -1,80 +0,0 @@ -getMimeType(); - $interlace = isset( $params['interlace'] ) && $params['interlace'] - && isset( $wgMaxInterlacingAreas[$mimeType] ) - && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType]; - $params['interlace'] = $interlace; - return true; - } - - /** - * Get ImageMagick subsampling factors for the target JPEG pixel format. - * - * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' - * @return array of string keys - */ - 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 File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @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, $wgJpegPixelFormat, - $wgJpegQuality; - - $quality = []; - $sharpen = []; - $scene = false; - $animation_pre = []; - $animation_post = []; - $decoderHint = []; - $subsampling = []; - - if ( $params['mimeType'] == 'image/jpeg' ) { - $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; - $quality = [ '-quality', $qualityVal ?: (string)$wgJpegQuality ]; // 80% by default - if ( $params['interlace'] ) { - $animation_post = [ '-interlace', 'JPEG' ]; - } - # Sharpening, see T8193 - if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) - / ( $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 ) ]; - } - } elseif ( $params['mimeType'] == 'image/png' ) { - $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 ) > $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 (T3017). - $animation_pre = [ '-coalesce' ]; - // We optimize the output, but -optimize is broken, - // use optimizeTransparency instead (T13822) - if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { - $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', - ]; - Wikimedia\suppressWarnings(); - $xcfMeta = unserialize( $image->getMetadata() ); - Wikimedia\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 = [ 'OMP_NUM_THREADS' => 1 ]; - if ( strval( $wgImageMagickTempDir ) !== '' ) { - $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; - } - - $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. - [ '-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', "{$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', $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" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval, $env ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - 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, $wgJpegQuality; - - 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 ?: $wgJpegQuality ); - 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 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 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', wfEscapeShellArg( $params['physicalHeight'] ), - str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size - wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return $this->getMediaTransformError( $params, $err ); - } - - return false; # No error - } - - /** - * Transform an image using the built in GD library - * - * @param File $image File associated with this thumbnail - * @param array $params Array with scaler params - * - * @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 = [ - '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 = wfMessage( 'thumbnail_image-type' )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']]; - - if ( !function_exists( $loader ) ) { - $err = "Incomplete GD library configuration: missing function $loader"; - wfDebug( "$err\n" ); - $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 = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - - if ( filesize( $params['srcPath'] ) === 0 ) { - $err = "Image file size seems to be zero."; - wfDebug( "$err\n" ); - $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text(); - - return $this->getMediaTransformError( $params, $errMsg ); - } - - $src_image = call_user_func( $loader, $params['srcPath'] ); - - $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 - $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 { - 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; - } - - imagesavealpha( $dst_image, true ); - - $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 ); - - return false; # No error - } - - /** - * Callback for transformGd when transforming jpeg images. - * - * @param resource $dst_image Image resource of the original image - * @param string $thumbPath File path to write the thumbnail image to - * @param int|null $quality Quality of the thumbnail from 1-100, - * or null to use default quality. - */ - static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) { - global $wgJpegQuality; - - if ( $quality === null ) { - $quality = $wgJpegQuality; - } - - imageinterlace( $dst_image ); - imagejpeg( $dst_image, $thumbPath, $quality ); - } - - /** - * Returns whether the current scaler supports rotation (im and gd do) - * - * @return bool - */ - 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; - } - } - - /** - * @see $wgEnableAutoRotation - * @return bool Whether auto rotation is enabled - */ - public function autoRotateEnabled() { - global $wgEnableAutoRotation; - - if ( $wgEnableAutoRotation === null ) { - // Only enable auto-rotation when we actually can - return $this->canRotate(); - } - - return $wgEnableAutoRotation; - } - - /** - * @param File $file - * @param array $params Rotate parameters. - * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 - * @since 1.21 - * @return bool|MediaTransformError - */ - public function rotate( $file, $params ) { - global $wgImageMagickConvertCommand; - - $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; - $scene = false; - - $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 ); - } - - 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']}" ); - } - - return false; - default: - return new MediaTransformError( 'thumbnail_error', 0, 0, - "$scaler rotation not implemented" ); - } - } -} diff --git a/includes/media/BitmapHandler.php b/includes/media/BitmapHandler.php new file mode 100644 index 0000000000..cda037c16d --- /dev/null +++ b/includes/media/BitmapHandler.php @@ -0,0 +1,607 @@ +getMimeType(); + $interlace = isset( $params['interlace'] ) && $params['interlace'] + && isset( $wgMaxInterlacingAreas[$mimeType] ) + && $this->getImageArea( $image ) <= $wgMaxInterlacingAreas[$mimeType]; + $params['interlace'] = $interlace; + return true; + } + + /** + * Get ImageMagick subsampling factors for the target JPEG pixel format. + * + * @param string $pixelFormat one of 'yuv444', 'yuv422', 'yuv420' + * @return array of string keys + */ + 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 File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @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, $wgJpegPixelFormat, + $wgJpegQuality; + + $quality = []; + $sharpen = []; + $scene = false; + $animation_pre = []; + $animation_post = []; + $decoderHint = []; + $subsampling = []; + + if ( $params['mimeType'] == 'image/jpeg' ) { + $qualityVal = isset( $params['quality'] ) ? (string)$params['quality'] : null; + $quality = [ '-quality', $qualityVal ?: (string)$wgJpegQuality ]; // 80% by default + if ( $params['interlace'] ) { + $animation_post = [ '-interlace', 'JPEG' ]; + } + # Sharpening, see T8193 + if ( ( $params['physicalWidth'] + $params['physicalHeight'] ) + / ( $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 ) ]; + } + } elseif ( $params['mimeType'] == 'image/png' ) { + $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 ) > $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 (T3017). + $animation_pre = [ '-coalesce' ]; + // We optimize the output, but -optimize is broken, + // use optimizeTransparency instead (T13822) + if ( version_compare( $this->getMagickVersion(), "6.3.5" ) >= 0 ) { + $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', + ]; + Wikimedia\suppressWarnings(); + $xcfMeta = unserialize( $image->getMetadata() ); + Wikimedia\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 = [ 'OMP_NUM_THREADS' => 1 ]; + if ( strval( $wgImageMagickTempDir ) !== '' ) { + $env['MAGICK_TMPDIR'] = $wgImageMagickTempDir; + } + + $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. + [ '-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', "{$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', $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" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval, $env ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + 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, $wgJpegQuality; + + 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 ?: $wgJpegQuality ); + 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 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 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', wfEscapeShellArg( $params['physicalHeight'] ), + str_replace( '%w', wfEscapeShellArg( $params['physicalWidth'] ), $cmd ) ); # Size + wfDebug( __METHOD__ . ": Running custom convert command $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return $this->getMediaTransformError( $params, $err ); + } + + return false; # No error + } + + /** + * Transform an image using the built in GD library + * + * @param File $image File associated with this thumbnail + * @param array $params Array with scaler params + * + * @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 = [ + '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 = wfMessage( 'thumbnail_image-type' )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + list( $loader, $colorStyle, $useQuality, $saveType ) = $typemap[$params['mimeType']]; + + if ( !function_exists( $loader ) ) { + $err = "Incomplete GD library configuration: missing function $loader"; + wfDebug( "$err\n" ); + $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 = wfMessage( 'thumbnail_image-missing', $params['srcPath'] )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + + if ( filesize( $params['srcPath'] ) === 0 ) { + $err = "Image file size seems to be zero."; + wfDebug( "$err\n" ); + $errMsg = wfMessage( 'thumbnail_image-size-zero', $params['srcPath'] )->text(); + + return $this->getMediaTransformError( $params, $errMsg ); + } + + $src_image = call_user_func( $loader, $params['srcPath'] ); + + $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 + $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 { + 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; + } + + imagesavealpha( $dst_image, true ); + + $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 ); + + return false; # No error + } + + /** + * Callback for transformGd when transforming jpeg images. + * + * @param resource $dst_image Image resource of the original image + * @param string $thumbPath File path to write the thumbnail image to + * @param int|null $quality Quality of the thumbnail from 1-100, + * or null to use default quality. + */ + static function imageJpegWrapper( $dst_image, $thumbPath, $quality = null ) { + global $wgJpegQuality; + + if ( $quality === null ) { + $quality = $wgJpegQuality; + } + + imageinterlace( $dst_image ); + imagejpeg( $dst_image, $thumbPath, $quality ); + } + + /** + * Returns whether the current scaler supports rotation (im and gd do) + * + * @return bool + */ + 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; + } + } + + /** + * @see $wgEnableAutoRotation + * @return bool Whether auto rotation is enabled + */ + public function autoRotateEnabled() { + global $wgEnableAutoRotation; + + if ( $wgEnableAutoRotation === null ) { + // Only enable auto-rotation when we actually can + return $this->canRotate(); + } + + return $wgEnableAutoRotation; + } + + /** + * @param File $file + * @param array $params Rotate parameters. + * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 + * @since 1.21 + * @return bool|MediaTransformError + */ + public function rotate( $file, $params ) { + global $wgImageMagickConvertCommand; + + $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; + $scene = false; + + $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 ); + } + + 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']}" ); + } + + return false; + default: + return new MediaTransformError( 'thumbnail_error', 0, 0, + "$scaler rotation not implemented" ); + } + } +} diff --git a/includes/media/BitmapHandler_ClientOnly.php b/includes/media/BitmapHandler_ClientOnly.php new file mode 100644 index 0000000000..fa5b0a61c6 --- /dev/null +++ b/includes/media/BitmapHandler_ClientOnly.php @@ -0,0 +1,59 @@ +normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + + return new ThumbnailImage( $image, $image->getUrl(), $image->getLocalRefPath(), $params ); + } +} diff --git a/includes/media/Bitmap_ClientOnly.php b/includes/media/Bitmap_ClientOnly.php deleted file mode 100644 index fa5b0a61c6..0000000000 --- a/includes/media/Bitmap_ClientOnly.php +++ /dev/null @@ -1,59 +0,0 @@ -normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - - return new ThumbnailImage( $image, $image->getUrl(), $image->getLocalRefPath(), $params ); - } -} diff --git a/includes/media/BmpHandler.php b/includes/media/BmpHandler.php new file mode 100644 index 0000000000..0229ac11b7 --- /dev/null +++ b/includes/media/BmpHandler.php @@ -0,0 +1,80 @@ +getSize() > static::EXPENSIVE_SIZE_LIMIT; - } - - /** - * @param File $file - * @return bool - */ - public function isMultiPage( $file ) { - return true; - } - - /** - * @return array - */ - public function getParamMap() { - return [ - 'img_width' => 'width', - 'img_page' => 'page', - ]; - } - - /** - * @param string $name - * @param mixed $value - * @return bool - */ - public function validateParam( $name, $value ) { - if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) { - // Extra junk on the end of page, probably actually a caption - // e.g. [[File:Foo.djvu|thumb|Page 3 of the document shows foo]] - return false; - } - if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) { - if ( $value <= 0 ) { - return false; - } else { - return true; - } - } else { - return false; - } - } - - /** - * @param array $params - * @return bool|string - */ - public function makeParamString( $params ) { - $page = isset( $params['page'] ) ? $params['page'] : 1; - if ( !isset( $params['width'] ) ) { - return false; - } - - return "page{$page}-{$params['width']}px"; - } - - /** - * @param string $str - * @return array|bool - */ - public function parseParamString( $str ) { - $m = false; - if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) { - return [ 'width' => $m[2], 'page' => $m[1] ]; - } else { - return false; - } - } - - /** - * @param array $params - * @return array - */ - function getScriptParams( $params ) { - return [ - 'width' => $params['width'], - 'page' => $params['page'], - ]; - } - - /** - * @param File $image - * @param string $dstPath - * @param string $dstUrl - * @param array $params - * @param int $flags - * @return MediaTransformError|ThumbnailImage|TransformParameterError - */ - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - global $wgDjvuRenderer, $wgDjvuPostProcessor; - - if ( !$this->normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - $width = $params['width']; - $height = $params['height']; - $page = $params['page']; - - if ( $flags & self::TRANSFORM_LATER ) { - $params = [ - 'width' => $width, - 'height' => $height, - 'page' => $page - ]; - - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - - if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { - return new MediaTransformError( - 'thumbnail_error', - $width, - $height, - wfMessage( 'thumbnail_dest_directory' ) - ); - } - - // Get local copy source for shell scripts - // Thumbnail extraction is very inefficient for large files. - // Provide a way to pool count limit the number of downloaders. - if ( $image->getSize() >= 1e7 ) { // 10MB - $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ), - [ - 'doWork' => function () use ( $image ) { - return $image->getLocalRefPath(); - } - ] - ); - $srcPath = $work->execute(); - } else { - $srcPath = $image->getLocalRefPath(); - } - - if ( $srcPath === false ) { // Failed to get local copy - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', - wfHostname(), $image->getName() ) ); - - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'filemissing' ) - ); - } - - # Use a subshell (brackets) to aggregate stderr from both pipeline commands - # before redirecting it to the overall stdout. This works in both Linux and Windows XP. - $cmd = '(' . wfEscapeShellArg( - $wgDjvuRenderer, - "-format=ppm", - "-page={$page}", - "-size={$params['physicalWidth']}x{$params['physicalHeight']}", - $srcPath ); - if ( $wgDjvuPostProcessor ) { - $cmd .= " | {$wgDjvuPostProcessor}"; - } - $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1'; - wfDebug( __METHOD__ . ": $cmd\n" ); - $retval = ''; - $err = wfShellExec( $cmd, $retval ); - - $removed = $this->removeBadFile( $dstPath, $retval ); - if ( $retval != 0 || $removed ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); - } else { - $params = [ - 'width' => $width, - 'height' => $height, - 'page' => $page - ]; - - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - } - - /** - * Cache an instance of DjVuImage in an Image object, return that instance - * - * @param File|FSFile $image - * @param string $path - * @return DjVuImage - */ - function getDjVuImage( $image, $path ) { - if ( !$image ) { - $deja = new DjVuImage( $path ); - } elseif ( !isset( $image->dejaImage ) ) { - $deja = $image->dejaImage = new DjVuImage( $path ); - } else { - $deja = $image->dejaImage; - } - - return $deja; - } - - /** - * Get metadata, unserializing it if neccessary. - * - * @param File $file The DjVu file in question - * @return string XML metadata as a string. - * @throws MWException - */ - private function getUnserializedMetadata( File $file ) { - $metadata = $file->getMetadata(); - if ( substr( $metadata, 0, 3 ) === 'djvuTextTree ) ) { - return $image->djvuTextTree; - } - if ( !$gettext && isset( $image->dejaMetaTree ) ) { - return $image->dejaMetaTree; - } - - $metadata = $this->getUnserializedMetadata( $image ); - if ( !$this->isMetadataValid( $image, $metadata ) ) { - wfDebug( "DjVu XML metadata is invalid or missing, should have been fixed in upgradeRow\n" ); - - return false; - } - - $trees = $this->extractTreesFromMetadata( $metadata ); - $image->djvuTextTree = $trees['TextTree']; - $image->dejaMetaTree = $trees['MetaTree']; - - if ( $gettext ) { - return $image->djvuTextTree; - } else { - return $image->dejaMetaTree; - } - } - - /** - * Extracts metadata and text trees from metadata XML in string form - * @param string $metadata XML metadata as a string - * @return array - */ - protected function extractTreesFromMetadata( $metadata ) { - Wikimedia\suppressWarnings(); - try { - // Set to false rather than null to avoid further attempts - $metaTree = false; - $textTree = false; - $tree = new SimpleXMLElement( $metadata, LIBXML_PARSEHUGE ); - if ( $tree->getName() == 'mw-djvu' ) { - /** @var SimpleXMLElement $b */ - foreach ( $tree->children() as $b ) { - if ( $b->getName() == 'DjVuTxt' ) { - // @todo File::djvuTextTree and File::dejaMetaTree are declared - // dynamically. Add a public File::$data to facilitate this? - $textTree = $b; - } elseif ( $b->getName() == 'DjVuXML' ) { - $metaTree = $b; - } - } - } else { - $metaTree = $tree; - } - } catch ( Exception $e ) { - wfDebug( "Bogus multipage XML metadata\n" ); - } - Wikimedia\restoreWarnings(); - - return [ 'MetaTree' => $metaTree, 'TextTree' => $textTree ]; - } - - function getImageSize( $image, $path ) { - return $this->getDjVuImage( $image, $path )->getImageSize(); - } - - function getThumbType( $ext, $mime, $params = null ) { - global $wgDjvuOutputExtension; - static $mime; - if ( !isset( $mime ) ) { - $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); - $mime = $magic->guessTypesForExtension( $wgDjvuOutputExtension ); - } - - return [ $wgDjvuOutputExtension, $mime ]; - } - - function getMetadata( $image, $path ) { - wfDebug( "Getting DjVu metadata for $path\n" ); - - $xml = $this->getDjVuImage( $image, $path )->retrieveMetaData(); - if ( $xml === false ) { - // Special value so that we don't repetitively try and decode a broken file. - return serialize( [ 'error' => 'Error extracting metadata' ] ); - } else { - return serialize( [ 'xml' => $xml ] ); - } - } - - function getMetadataType( $image ) { - return 'djvuxml'; - } - - function isMetadataValid( $image, $metadata ) { - return !empty( $metadata ) && $metadata != serialize( [] ); - } - - function pageCount( File $image ) { - $info = $this->getDimensionInfo( $image ); - - return $info ? $info['pageCount'] : false; - } - - function getPageDimensions( File $image, $page ) { - $index = $page - 1; // MW starts pages at 1 - - $info = $this->getDimensionInfo( $image ); - if ( $info && isset( $info['dimensionsByPage'][$index] ) ) { - return $info['dimensionsByPage'][$index]; - } - - return false; - } - - protected function getDimensionInfo( File $file ) { - $cache = ObjectCache::getMainWANInstance(); - return $cache->getWithSetCallback( - $cache->makeKey( 'file-djvu', 'dimensions', $file->getSha1() ), - $cache::TTL_INDEFINITE, - function () use ( $file ) { - $tree = $this->getMetaTree( $file ); - return $this->getDimensionInfoFromMetaTree( $tree ); - }, - [ 'pcTTL' => $cache::TTL_INDEFINITE ] - ); - } - - /** - * Given an XML metadata tree, returns dimension information about the document - * @param bool|SimpleXMLElement $metatree The file's XML metadata tree - * @return bool|array - */ - protected function getDimensionInfoFromMetaTree( $metatree ) { - if ( !$metatree ) { - return false; - } - - $dimsByPage = []; - $count = count( $metatree->xpath( '//OBJECT' ) ); - for ( $i = 0; $i < $count; $i++ ) { - $o = $metatree->BODY[0]->OBJECT[$i]; - if ( $o ) { - $dimsByPage[$i] = [ - 'width' => (int)$o['width'], - 'height' => (int)$o['height'], - ]; - } else { - $dimsByPage[$i] = false; - } - } - - return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ]; - } - - /** - * @param File $image - * @param int $page Page number to get information for - * @return bool|string Page text or false when no text found. - */ - function getPageText( File $image, $page ) { - $tree = $this->getMetaTree( $image, true ); - if ( !$tree ) { - return false; - } - - $o = $tree->BODY[0]->PAGE[$page - 1]; - if ( $o ) { - $txt = $o['value']; - - return $txt; - } else { - return false; - } - } -} diff --git a/includes/media/DjVuHandler.php b/includes/media/DjVuHandler.php new file mode 100644 index 0000000000..2541e35bc5 --- /dev/null +++ b/includes/media/DjVuHandler.php @@ -0,0 +1,464 @@ +getSize() > static::EXPENSIVE_SIZE_LIMIT; + } + + /** + * @param File $file + * @return bool + */ + public function isMultiPage( $file ) { + return true; + } + + /** + * @return array + */ + public function getParamMap() { + return [ + 'img_width' => 'width', + 'img_page' => 'page', + ]; + } + + /** + * @param string $name + * @param mixed $value + * @return bool + */ + public function validateParam( $name, $value ) { + if ( $name === 'page' && trim( $value ) !== (string)intval( $value ) ) { + // Extra junk on the end of page, probably actually a caption + // e.g. [[File:Foo.djvu|thumb|Page 3 of the document shows foo]] + return false; + } + if ( in_array( $name, [ 'width', 'height', 'page' ] ) ) { + if ( $value <= 0 ) { + return false; + } else { + return true; + } + } else { + return false; + } + } + + /** + * @param array $params + * @return bool|string + */ + public function makeParamString( $params ) { + $page = isset( $params['page'] ) ? $params['page'] : 1; + if ( !isset( $params['width'] ) ) { + return false; + } + + return "page{$page}-{$params['width']}px"; + } + + /** + * @param string $str + * @return array|bool + */ + public function parseParamString( $str ) { + $m = false; + if ( preg_match( '/^page(\d+)-(\d+)px$/', $str, $m ) ) { + return [ 'width' => $m[2], 'page' => $m[1] ]; + } else { + return false; + } + } + + /** + * @param array $params + * @return array + */ + function getScriptParams( $params ) { + return [ + 'width' => $params['width'], + 'page' => $params['page'], + ]; + } + + /** + * @param File $image + * @param string $dstPath + * @param string $dstUrl + * @param array $params + * @param int $flags + * @return MediaTransformError|ThumbnailImage|TransformParameterError + */ + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { + global $wgDjvuRenderer, $wgDjvuPostProcessor; + + if ( !$this->normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + $width = $params['width']; + $height = $params['height']; + $page = $params['page']; + + if ( $flags & self::TRANSFORM_LATER ) { + $params = [ + 'width' => $width, + 'height' => $height, + 'page' => $page + ]; + + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + + if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { + return new MediaTransformError( + 'thumbnail_error', + $width, + $height, + wfMessage( 'thumbnail_dest_directory' ) + ); + } + + // Get local copy source for shell scripts + // Thumbnail extraction is very inefficient for large files. + // Provide a way to pool count limit the number of downloaders. + if ( $image->getSize() >= 1e7 ) { // 10MB + $work = new PoolCounterWorkViaCallback( 'GetLocalFileCopy', sha1( $image->getName() ), + [ + 'doWork' => function () use ( $image ) { + return $image->getLocalRefPath(); + } + ] + ); + $srcPath = $work->execute(); + } else { + $srcPath = $image->getLocalRefPath(); + } + + if ( $srcPath === false ) { // Failed to get local copy + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', + wfHostname(), $image->getName() ) ); + + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'filemissing' ) + ); + } + + # Use a subshell (brackets) to aggregate stderr from both pipeline commands + # before redirecting it to the overall stdout. This works in both Linux and Windows XP. + $cmd = '(' . wfEscapeShellArg( + $wgDjvuRenderer, + "-format=ppm", + "-page={$page}", + "-size={$params['physicalWidth']}x{$params['physicalHeight']}", + $srcPath ); + if ( $wgDjvuPostProcessor ) { + $cmd .= " | {$wgDjvuPostProcessor}"; + } + $cmd .= ' > ' . wfEscapeShellArg( $dstPath ) . ') 2>&1'; + wfDebug( __METHOD__ . ": $cmd\n" ); + $retval = ''; + $err = wfShellExec( $cmd, $retval ); + + $removed = $this->removeBadFile( $dstPath, $retval ); + if ( $retval != 0 || $removed ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); + } else { + $params = [ + 'width' => $width, + 'height' => $height, + 'page' => $page + ]; + + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + } + + /** + * Cache an instance of DjVuImage in an Image object, return that instance + * + * @param File|FSFile $image + * @param string $path + * @return DjVuImage + */ + function getDjVuImage( $image, $path ) { + if ( !$image ) { + $deja = new DjVuImage( $path ); + } elseif ( !isset( $image->dejaImage ) ) { + $deja = $image->dejaImage = new DjVuImage( $path ); + } else { + $deja = $image->dejaImage; + } + + return $deja; + } + + /** + * Get metadata, unserializing it if neccessary. + * + * @param File $file The DjVu file in question + * @return string XML metadata as a string. + * @throws MWException + */ + private function getUnserializedMetadata( File $file ) { + $metadata = $file->getMetadata(); + if ( substr( $metadata, 0, 3 ) === 'djvuTextTree ) ) { + return $image->djvuTextTree; + } + if ( !$gettext && isset( $image->dejaMetaTree ) ) { + return $image->dejaMetaTree; + } + + $metadata = $this->getUnserializedMetadata( $image ); + if ( !$this->isMetadataValid( $image, $metadata ) ) { + wfDebug( "DjVu XML metadata is invalid or missing, should have been fixed in upgradeRow\n" ); + + return false; + } + + $trees = $this->extractTreesFromMetadata( $metadata ); + $image->djvuTextTree = $trees['TextTree']; + $image->dejaMetaTree = $trees['MetaTree']; + + if ( $gettext ) { + return $image->djvuTextTree; + } else { + return $image->dejaMetaTree; + } + } + + /** + * Extracts metadata and text trees from metadata XML in string form + * @param string $metadata XML metadata as a string + * @return array + */ + protected function extractTreesFromMetadata( $metadata ) { + Wikimedia\suppressWarnings(); + try { + // Set to false rather than null to avoid further attempts + $metaTree = false; + $textTree = false; + $tree = new SimpleXMLElement( $metadata, LIBXML_PARSEHUGE ); + if ( $tree->getName() == 'mw-djvu' ) { + /** @var SimpleXMLElement $b */ + foreach ( $tree->children() as $b ) { + if ( $b->getName() == 'DjVuTxt' ) { + // @todo File::djvuTextTree and File::dejaMetaTree are declared + // dynamically. Add a public File::$data to facilitate this? + $textTree = $b; + } elseif ( $b->getName() == 'DjVuXML' ) { + $metaTree = $b; + } + } + } else { + $metaTree = $tree; + } + } catch ( Exception $e ) { + wfDebug( "Bogus multipage XML metadata\n" ); + } + Wikimedia\restoreWarnings(); + + return [ 'MetaTree' => $metaTree, 'TextTree' => $textTree ]; + } + + function getImageSize( $image, $path ) { + return $this->getDjVuImage( $image, $path )->getImageSize(); + } + + function getThumbType( $ext, $mime, $params = null ) { + global $wgDjvuOutputExtension; + static $mime; + if ( !isset( $mime ) ) { + $magic = MediaWiki\MediaWikiServices::getInstance()->getMimeAnalyzer(); + $mime = $magic->guessTypesForExtension( $wgDjvuOutputExtension ); + } + + return [ $wgDjvuOutputExtension, $mime ]; + } + + function getMetadata( $image, $path ) { + wfDebug( "Getting DjVu metadata for $path\n" ); + + $xml = $this->getDjVuImage( $image, $path )->retrieveMetaData(); + if ( $xml === false ) { + // Special value so that we don't repetitively try and decode a broken file. + return serialize( [ 'error' => 'Error extracting metadata' ] ); + } else { + return serialize( [ 'xml' => $xml ] ); + } + } + + function getMetadataType( $image ) { + return 'djvuxml'; + } + + function isMetadataValid( $image, $metadata ) { + return !empty( $metadata ) && $metadata != serialize( [] ); + } + + function pageCount( File $image ) { + $info = $this->getDimensionInfo( $image ); + + return $info ? $info['pageCount'] : false; + } + + function getPageDimensions( File $image, $page ) { + $index = $page - 1; // MW starts pages at 1 + + $info = $this->getDimensionInfo( $image ); + if ( $info && isset( $info['dimensionsByPage'][$index] ) ) { + return $info['dimensionsByPage'][$index]; + } + + return false; + } + + protected function getDimensionInfo( File $file ) { + $cache = ObjectCache::getMainWANInstance(); + return $cache->getWithSetCallback( + $cache->makeKey( 'file-djvu', 'dimensions', $file->getSha1() ), + $cache::TTL_INDEFINITE, + function () use ( $file ) { + $tree = $this->getMetaTree( $file ); + return $this->getDimensionInfoFromMetaTree( $tree ); + }, + [ 'pcTTL' => $cache::TTL_INDEFINITE ] + ); + } + + /** + * Given an XML metadata tree, returns dimension information about the document + * @param bool|SimpleXMLElement $metatree The file's XML metadata tree + * @return bool|array + */ + protected function getDimensionInfoFromMetaTree( $metatree ) { + if ( !$metatree ) { + return false; + } + + $dimsByPage = []; + $count = count( $metatree->xpath( '//OBJECT' ) ); + for ( $i = 0; $i < $count; $i++ ) { + $o = $metatree->BODY[0]->OBJECT[$i]; + if ( $o ) { + $dimsByPage[$i] = [ + 'width' => (int)$o['width'], + 'height' => (int)$o['height'], + ]; + } else { + $dimsByPage[$i] = false; + } + } + + return [ 'pageCount' => $count, 'dimensionsByPage' => $dimsByPage ]; + } + + /** + * @param File $image + * @param int $page Page number to get information for + * @return bool|string Page text or false when no text found. + */ + function getPageText( File $image, $page ) { + $tree = $this->getMetaTree( $image, true ); + if ( !$tree ) { + return false; + } + + $o = $tree->BODY[0]->PAGE[$page - 1]; + if ( $o ) { + $txt = $o['value']; + + return $txt; + } else { + return false; + } + } +} diff --git a/includes/media/ExifBitmap.php b/includes/media/ExifBitmap.php deleted file mode 100644 index 426721095c..0000000000 --- a/includes/media/ExifBitmap.php +++ /dev/null @@ -1,245 +0,0 @@ -= 2 ) { - return $metadata; - } - - $avoidHtml = true; - - if ( !is_array( $metadata ) ) { - $metadata = unserialize( $metadata ); - } - if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) { - return $metadata; - } - - // Treat Software as a special case because in can contain - // an array of (SoftwareName, Version). - if ( isset( $metadata['Software'] ) - && is_array( $metadata['Software'] ) - && is_array( $metadata['Software'][0] ) - && isset( $metadata['Software'][0][0] ) - && isset( $metadata['Software'][0][1] ) - ) { - $metadata['Software'] = $metadata['Software'][0][0] . ' (Version ' - . $metadata['Software'][0][1] . ')'; - } - - $formatter = new FormatMetadata; - - // ContactInfo also has to be dealt with specially - if ( isset( $metadata['Contact'] ) ) { - $metadata['Contact'] = - $formatter->collapseContactInfo( - $metadata['Contact'] ); - } - - foreach ( $metadata as &$val ) { - if ( is_array( $val ) ) { - $val = $formatter->flattenArrayReal( $val, 'ul', $avoidHtml ); - } - } - $metadata['MEDIAWIKI_EXIF_VERSION'] = 1; - - return $metadata; - } - - /** - * @param File $image - * @param array $metadata - * @return bool|int - */ - function isMetadataValid( $image, $metadata ) { - global $wgShowEXIF; - if ( !$wgShowEXIF ) { - # Metadata disabled and so an empty field is expected - return self::METADATA_GOOD; - } - if ( $metadata === self::OLD_BROKEN_FILE ) { - # Old special value indicating that there is no Exif data in the file. - # or that there was an error well extracting the metadata. - wfDebug( __METHOD__ . ": back-compat version\n" ); - - return self::METADATA_COMPATIBLE; - } - if ( $metadata === self::BROKEN_FILE ) { - return self::METADATA_GOOD; - } - Wikimedia\suppressWarnings(); - $exif = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) - || $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() - ) { - if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) - && $exif['MEDIAWIKI_EXIF_VERSION'] == 1 - ) { - // back-compatible but old - wfDebug( __METHOD__ . ": back-compat version\n" ); - - return self::METADATA_COMPATIBLE; - } - # Wrong (non-compatible) version - wfDebug( __METHOD__ . ": wrong version\n" ); - - return self::METADATA_BAD; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - public function getCommonMetaArray( File $file ) { - $metadata = $file->getMetadata(); - if ( $metadata === self::OLD_BROKEN_FILE - || $metadata === self::BROKEN_FILE - || $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD - ) { - // So we don't try and display metadata from PagedTiffHandler - // for example when using InstantCommons. - return []; - } - - $exif = unserialize( $metadata ); - if ( !$exif ) { - return []; - } - unset( $exif['MEDIAWIKI_EXIF_VERSION'] ); - - return $exif; - } - - function getMetadataType( $image ) { - return 'exif'; - } - - /** - * Wrapper for base classes ImageHandler::getImageSize() that checks for - * rotation reported from metadata and swaps the sizes to match. - * - * @param File|FSFile $image - * @param string $path - * @return array - */ - function getImageSize( $image, $path ) { - $gis = parent::getImageSize( $image, $path ); - - // Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object. - // This may mean we read EXIF data twice on initial upload. - if ( $this->autoRotateEnabled() ) { - $meta = $this->getMetadata( $image, $path ); - $rotation = $this->getRotationForExif( $meta ); - } else { - $rotation = 0; - } - - if ( $rotation == 90 || $rotation == 270 ) { - $width = $gis[0]; - $gis[0] = $gis[1]; - $gis[1] = $width; - } - - return $gis; - } - - /** - * 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. - * - * @param File $file - * @return int 0, 90, 180 or 270 - */ - public function getRotation( $file ) { - if ( !$this->autoRotateEnabled() ) { - return 0; - } - - $data = $file->getMetadata(); - - return $this->getRotationForExif( $data ); - } - - /** - * Given a chunk of serialized Exif metadata, return the orientation as - * degrees of rotation. - * - * @param string $data - * @return int 0, 90, 180 or 270 - * @todo FIXME: Orientation can include flipping as well; see if this is an issue! - */ - protected function getRotationForExif( $data ) { - if ( !$data ) { - return 0; - } - Wikimedia\suppressWarnings(); - $data = unserialize( $data ); - Wikimedia\restoreWarnings(); - if ( isset( $data['Orientation'] ) ) { - # See http://sylvana.net/jpegcrop/exif_orientation.html - switch ( $data['Orientation'] ) { - case 8: - return 90; - case 3: - return 180; - case 6: - return 270; - default: - return 0; - } - } - - return 0; - } -} diff --git a/includes/media/ExifBitmapHandler.php b/includes/media/ExifBitmapHandler.php new file mode 100644 index 0000000000..426721095c --- /dev/null +++ b/includes/media/ExifBitmapHandler.php @@ -0,0 +1,245 @@ += 2 ) { + return $metadata; + } + + $avoidHtml = true; + + if ( !is_array( $metadata ) ) { + $metadata = unserialize( $metadata ); + } + if ( !isset( $metadata['MEDIAWIKI_EXIF_VERSION'] ) || $metadata['MEDIAWIKI_EXIF_VERSION'] != 2 ) { + return $metadata; + } + + // Treat Software as a special case because in can contain + // an array of (SoftwareName, Version). + if ( isset( $metadata['Software'] ) + && is_array( $metadata['Software'] ) + && is_array( $metadata['Software'][0] ) + && isset( $metadata['Software'][0][0] ) + && isset( $metadata['Software'][0][1] ) + ) { + $metadata['Software'] = $metadata['Software'][0][0] . ' (Version ' + . $metadata['Software'][0][1] . ')'; + } + + $formatter = new FormatMetadata; + + // ContactInfo also has to be dealt with specially + if ( isset( $metadata['Contact'] ) ) { + $metadata['Contact'] = + $formatter->collapseContactInfo( + $metadata['Contact'] ); + } + + foreach ( $metadata as &$val ) { + if ( is_array( $val ) ) { + $val = $formatter->flattenArrayReal( $val, 'ul', $avoidHtml ); + } + } + $metadata['MEDIAWIKI_EXIF_VERSION'] = 1; + + return $metadata; + } + + /** + * @param File $image + * @param array $metadata + * @return bool|int + */ + function isMetadataValid( $image, $metadata ) { + global $wgShowEXIF; + if ( !$wgShowEXIF ) { + # Metadata disabled and so an empty field is expected + return self::METADATA_GOOD; + } + if ( $metadata === self::OLD_BROKEN_FILE ) { + # Old special value indicating that there is no Exif data in the file. + # or that there was an error well extracting the metadata. + wfDebug( __METHOD__ . ": back-compat version\n" ); + + return self::METADATA_COMPATIBLE; + } + if ( $metadata === self::BROKEN_FILE ) { + return self::METADATA_GOOD; + } + Wikimedia\suppressWarnings(); + $exif = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + if ( !isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) + || $exif['MEDIAWIKI_EXIF_VERSION'] != Exif::version() + ) { + if ( isset( $exif['MEDIAWIKI_EXIF_VERSION'] ) + && $exif['MEDIAWIKI_EXIF_VERSION'] == 1 + ) { + // back-compatible but old + wfDebug( __METHOD__ . ": back-compat version\n" ); + + return self::METADATA_COMPATIBLE; + } + # Wrong (non-compatible) version + wfDebug( __METHOD__ . ": wrong version\n" ); + + return self::METADATA_BAD; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + public function getCommonMetaArray( File $file ) { + $metadata = $file->getMetadata(); + if ( $metadata === self::OLD_BROKEN_FILE + || $metadata === self::BROKEN_FILE + || $this->isMetadataValid( $file, $metadata ) === self::METADATA_BAD + ) { + // So we don't try and display metadata from PagedTiffHandler + // for example when using InstantCommons. + return []; + } + + $exif = unserialize( $metadata ); + if ( !$exif ) { + return []; + } + unset( $exif['MEDIAWIKI_EXIF_VERSION'] ); + + return $exif; + } + + function getMetadataType( $image ) { + return 'exif'; + } + + /** + * Wrapper for base classes ImageHandler::getImageSize() that checks for + * rotation reported from metadata and swaps the sizes to match. + * + * @param File|FSFile $image + * @param string $path + * @return array + */ + function getImageSize( $image, $path ) { + $gis = parent::getImageSize( $image, $path ); + + // Don't just call $image->getMetadata(); FSFile::getPropsFromPath() calls us with a bogus object. + // This may mean we read EXIF data twice on initial upload. + if ( $this->autoRotateEnabled() ) { + $meta = $this->getMetadata( $image, $path ); + $rotation = $this->getRotationForExif( $meta ); + } else { + $rotation = 0; + } + + if ( $rotation == 90 || $rotation == 270 ) { + $width = $gis[0]; + $gis[0] = $gis[1]; + $gis[1] = $width; + } + + return $gis; + } + + /** + * 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. + * + * @param File $file + * @return int 0, 90, 180 or 270 + */ + public function getRotation( $file ) { + if ( !$this->autoRotateEnabled() ) { + return 0; + } + + $data = $file->getMetadata(); + + return $this->getRotationForExif( $data ); + } + + /** + * Given a chunk of serialized Exif metadata, return the orientation as + * degrees of rotation. + * + * @param string $data + * @return int 0, 90, 180 or 270 + * @todo FIXME: Orientation can include flipping as well; see if this is an issue! + */ + protected function getRotationForExif( $data ) { + if ( !$data ) { + return 0; + } + Wikimedia\suppressWarnings(); + $data = unserialize( $data ); + Wikimedia\restoreWarnings(); + if ( isset( $data['Orientation'] ) ) { + # See http://sylvana.net/jpegcrop/exif_orientation.html + switch ( $data['Orientation'] ) { + case 8: + return 90; + case 3: + return 180; + case 6: + return 270; + default: + return 0; + } + } + + return 0; + } +} diff --git a/includes/media/GIF.php b/includes/media/GIF.php deleted file mode 100644 index d65f872634..0000000000 --- a/includes/media/GIF.php +++ /dev/null @@ -1,211 +0,0 @@ -getMessage() . "\n" ); - - return self::BROKEN_FILE; - } - - return serialize( $parsedGIFMetadata ); - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - /** - * Return the standard metadata elements for #filemetadata parser func. - * @param File $image - * @return array|bool - */ - public function getCommonMetaArray( File $image ) { - $meta = $image->getMetadata(); - - if ( !$meta ) { - return []; - } - $meta = unserialize( $meta ); - if ( !isset( $meta['metadata'] ) ) { - return []; - } - unset( $meta['metadata']['_MW_GIF_VERSION'] ); - - return $meta['metadata']; - } - - /** - * @todo Add unit tests - * - * @param File $image - * @return bool - */ - function getImageArea( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - - return $image->getWidth() * $image->getHeight() * $metadata['frameCount']; - } else { - return $image->getWidth() * $image->getHeight(); - } - } - - /** - * @param File $image - * @return bool - */ - function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( $metadata['frameCount'] > 1 ) { - return true; - } - } - - return false; - } - - /** - * We cannot animate thumbnails that are bigger than a particular size - * @param File $file - * @return bool - */ - function canAnimateThumbnail( $file ) { - global $wgMaxAnimatedGifArea; - $answer = $this->getImageArea( $file ) <= $wgMaxAnimatedGifArea; - - return $answer; - } - - function getMetadataType( $image ) { - return 'parsed-gif'; - } - - function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid GIF metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_GIF_VERSION'] ) - || $data['metadata']['_MW_GIF_VERSION'] != GIFMetadataExtractor::VERSION - ) { - wfDebug( __METHOD__ . " old but compatible GIF metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @return string - */ - function getLongDesc( $image ) { - global $wgLang; - - $original = parent::getLongDesc( $image ); - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $image->getMetadata() ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || $metadata['frameCount'] <= 1 ) { - return $original; - } - - /* Preserve original image info string, but strip the last char ')' so we can add even more */ - $info = []; - $info[] = $original; - - if ( $metadata['looped'] ) { - $info[] = wfMessage( 'file-info-gif-looped' )->parse(); - } - - if ( $metadata['frameCount'] > 1 ) { - $info[] = wfMessage( 'file-info-gif-frames' )->numParams( $metadata['frameCount'] )->parse(); - } - - if ( $metadata['duration'] ) { - $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); - } - - return $wgLang->commaList( $info ); - } - - /** - * Return the duration of the GIF file. - * - * Shown in the &query=imageinfo&iiprop=size api query. - * - * @param File $file - * @return float The duration of the file. - */ - public function getLength( $file ) { - $serMeta = $file->getMetadata(); - Wikimedia\suppressWarnings(); - $metadata = unserialize( $serMeta ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { - return 0.0; - } else { - return (float)$metadata['duration']; - } - } -} diff --git a/includes/media/GIFHandler.php b/includes/media/GIFHandler.php new file mode 100644 index 0000000000..d65f872634 --- /dev/null +++ b/includes/media/GIFHandler.php @@ -0,0 +1,211 @@ +getMessage() . "\n" ); + + return self::BROKEN_FILE; + } + + return serialize( $parsedGIFMetadata ); + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + /** + * Return the standard metadata elements for #filemetadata parser func. + * @param File $image + * @return array|bool + */ + public function getCommonMetaArray( File $image ) { + $meta = $image->getMetadata(); + + if ( !$meta ) { + return []; + } + $meta = unserialize( $meta ); + if ( !isset( $meta['metadata'] ) ) { + return []; + } + unset( $meta['metadata']['_MW_GIF_VERSION'] ); + + return $meta['metadata']; + } + + /** + * @todo Add unit tests + * + * @param File $image + * @return bool + */ + function getImageArea( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + + return $image->getWidth() * $image->getHeight() * $metadata['frameCount']; + } else { + return $image->getWidth() * $image->getHeight(); + } + } + + /** + * @param File $image + * @return bool + */ + function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( $metadata['frameCount'] > 1 ) { + return true; + } + } + + return false; + } + + /** + * We cannot animate thumbnails that are bigger than a particular size + * @param File $file + * @return bool + */ + function canAnimateThumbnail( $file ) { + global $wgMaxAnimatedGifArea; + $answer = $this->getImageArea( $file ) <= $wgMaxAnimatedGifArea; + + return $answer; + } + + function getMetadataType( $image ) { + return 'parsed-gif'; + } + + function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid GIF metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_GIF_VERSION'] ) + || $data['metadata']['_MW_GIF_VERSION'] != GIFMetadataExtractor::VERSION + ) { + wfDebug( __METHOD__ . " old but compatible GIF metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @return string + */ + function getLongDesc( $image ) { + global $wgLang; + + $original = parent::getLongDesc( $image ); + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $image->getMetadata() ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || $metadata['frameCount'] <= 1 ) { + return $original; + } + + /* Preserve original image info string, but strip the last char ')' so we can add even more */ + $info = []; + $info[] = $original; + + if ( $metadata['looped'] ) { + $info[] = wfMessage( 'file-info-gif-looped' )->parse(); + } + + if ( $metadata['frameCount'] > 1 ) { + $info[] = wfMessage( 'file-info-gif-frames' )->numParams( $metadata['frameCount'] )->parse(); + } + + if ( $metadata['duration'] ) { + $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); + } + + return $wgLang->commaList( $info ); + } + + /** + * Return the duration of the GIF file. + * + * Shown in the &query=imageinfo&iiprop=size api query. + * + * @param File $file + * @return float The duration of the file. + */ + public function getLength( $file ) { + $serMeta = $file->getMetadata(); + Wikimedia\suppressWarnings(); + $metadata = unserialize( $serMeta ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { + return 0.0; + } else { + return (float)$metadata['duration']; + } + } +} diff --git a/includes/media/Jpeg.php b/includes/media/Jpeg.php deleted file mode 100644 index 287c198c57..0000000000 --- a/includes/media/Jpeg.php +++ /dev/null @@ -1,290 +0,0 @@ -getMessage() . "\n" ); - - /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases - * * No metadata in the file - * * Something is broken in the file. - * However, if the metadata support gets expanded then you can't tell if the 0 is from - * a broken file, or just no props found. A broken file is likely to stay broken, but - * a file which had no props could have props once the metadata support is improved. - * Thus switch to using -1 to denote only a broken file, and use an array with only - * MEDIAWIKI_EXIF_VERSION to denote no props. - */ - - return ExifBitmapHandler::BROKEN_FILE; - } - } - - /** - * @param File $file - * @param array $params Rotate parameters. - * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 - * @since 1.21 - * @return bool|MediaTransformError - */ - public function rotate( $file, $params ) { - global $wgJpegTran; - - $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; - - if ( $wgJpegTran && is_executable( $wgJpegTran ) ) { - $cmd = wfEscapeShellArg( $wgJpegTran ) . - " -rotate " . wfEscapeShellArg( $rotation ) . - " -outfile " . wfEscapeShellArg( $params['dstPath'] ) . - " " . wfEscapeShellArg( $params['srcPath'] ); - wfDebug( __METHOD__ . ": running jpgtran: $cmd\n" ); - $retval = 0; - $err = wfShellExecWithStderr( $cmd, $retval ); - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - - return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); - } - - return false; - } else { - return parent::rotate( $file, $params ); - } - } - - public function supportsBucketing() { - return true; - } - - public function sanitizeParamsForBucketing( $params ) { - $params = parent::sanitizeParamsForBucketing( $params ); - - // Quality needs to be cleared for bucketing. Buckets need to be default quality - if ( isset( $params['quality'] ) ) { - unset( $params['quality'] ); - } - - return $params; - } - - /** - * @inheritDoc - */ - protected function transformImageMagick( $image, $params ) { - global $wgUseTinyRGBForJPGThumbnails; - - $ret = parent::transformImageMagick( $image, $params ); - - if ( $ret ) { - return $ret; - } - - if ( $wgUseTinyRGBForJPGThumbnails ) { - // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller - // (and free) TinyRGB - - /** - * We'll want to replace the color profile for JPGs: - * * in the sRGB color space, or with the sRGB profile - * (other profiles will be left untouched) - * * without color space or profile, in which case browsers - * should assume sRGB, but don't always do (e.g. on wide-gamut - * monitors (unless it's meant for low bandwith) - * @see https://phabricator.wikimedia.org/T134498 - */ - $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ]; - $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ]; - - // we'll also add TinyRGB profile to images lacking a profile, but - // only if they're not low quality (which are meant to save bandwith - // and we don't want to increase the filesize by adding a profile) - if ( isset( $params['quality'] ) && $params['quality'] > 30 ) { - $profiles[] = '-'; - } - - $this->swapICCProfile( - $params['dstPath'], - $colorSpaces, - $profiles, - realpath( __DIR__ ) . '/tinyrgb.icc' - ); - } - - return false; - } - - /** - * Swaps an embedded ICC profile for another, if found. - * Depends on exiftool, no-op if not installed. - * @param string $filepath File to be manipulated (will be overwritten) - * @param array $colorSpaces Only process files with this/these Color Space(s) - * @param array $oldProfileStrings Exact name(s) of color profile to look for - * (the one that will be replaced) - * @param string $profileFilepath ICC profile file to apply to the file - * @since 1.26 - * @return bool - */ - public function swapICCProfile( $filepath, array $colorSpaces, - array $oldProfileStrings, $profileFilepath - ) { - global $wgExiftool; - - if ( !$wgExiftool || !is_executable( $wgExiftool ) ) { - return false; - } - - $cmd = wfEscapeShellArg( $wgExiftool, - '-EXIF:ColorSpace', - '-ICC_Profile:ProfileDescription', - '-S', - '-T', - $filepath - ); - - $output = wfShellExecWithStderr( $cmd, $retval ); - - // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc] - $data = explode( "\t", trim( $output ) ); - - if ( $retval !== 0 ) { - return false; - } - - // Make a regex out of the source data to match it to an array of color - // spaces in a case-insensitive way - $colorSpaceRegex = '/'.preg_quote( $data[0], '/' ).'/i'; - if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) { - // We can't establish that this file matches the color space, don't process it - return false; - } - - $profileRegex = '/'.preg_quote( $data[1], '/' ).'/i'; - if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) { - // We can't establish that this file has the expected ICC profile, don't process it - return false; - } - - $cmd = wfEscapeShellArg( $wgExiftool, - '-overwrite_original', - '-icc_profile<=' . $profileFilepath, - $filepath - ); - - $output = wfShellExecWithStderr( $cmd, $retval ); - - if ( $retval !== 0 ) { - $this->logErrorForExternalProcess( $retval, $output, $cmd ); - - return false; - } - - return true; - } -} diff --git a/includes/media/JpegHandler.php b/includes/media/JpegHandler.php new file mode 100644 index 0000000000..287c198c57 --- /dev/null +++ b/includes/media/JpegHandler.php @@ -0,0 +1,290 @@ +getMessage() . "\n" ); + + /* This used to use 0 (ExifBitmapHandler::OLD_BROKEN_FILE) for the cases + * * No metadata in the file + * * Something is broken in the file. + * However, if the metadata support gets expanded then you can't tell if the 0 is from + * a broken file, or just no props found. A broken file is likely to stay broken, but + * a file which had no props could have props once the metadata support is improved. + * Thus switch to using -1 to denote only a broken file, and use an array with only + * MEDIAWIKI_EXIF_VERSION to denote no props. + */ + + return ExifBitmapHandler::BROKEN_FILE; + } + } + + /** + * @param File $file + * @param array $params Rotate parameters. + * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 + * @since 1.21 + * @return bool|MediaTransformError + */ + public function rotate( $file, $params ) { + global $wgJpegTran; + + $rotation = ( $params['rotation'] + $this->getRotation( $file ) ) % 360; + + if ( $wgJpegTran && is_executable( $wgJpegTran ) ) { + $cmd = wfEscapeShellArg( $wgJpegTran ) . + " -rotate " . wfEscapeShellArg( $rotation ) . + " -outfile " . wfEscapeShellArg( $params['dstPath'] ) . + " " . wfEscapeShellArg( $params['srcPath'] ); + wfDebug( __METHOD__ . ": running jpgtran: $cmd\n" ); + $retval = 0; + $err = wfShellExecWithStderr( $cmd, $retval ); + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + + return new MediaTransformError( 'thumbnail_error', 0, 0, $err ); + } + + return false; + } else { + return parent::rotate( $file, $params ); + } + } + + public function supportsBucketing() { + return true; + } + + public function sanitizeParamsForBucketing( $params ) { + $params = parent::sanitizeParamsForBucketing( $params ); + + // Quality needs to be cleared for bucketing. Buckets need to be default quality + if ( isset( $params['quality'] ) ) { + unset( $params['quality'] ); + } + + return $params; + } + + /** + * @inheritDoc + */ + protected function transformImageMagick( $image, $params ) { + global $wgUseTinyRGBForJPGThumbnails; + + $ret = parent::transformImageMagick( $image, $params ); + + if ( $ret ) { + return $ret; + } + + if ( $wgUseTinyRGBForJPGThumbnails ) { + // T100976 If the profile embedded in the JPG is sRGB, swap it for the smaller + // (and free) TinyRGB + + /** + * We'll want to replace the color profile for JPGs: + * * in the sRGB color space, or with the sRGB profile + * (other profiles will be left untouched) + * * without color space or profile, in which case browsers + * should assume sRGB, but don't always do (e.g. on wide-gamut + * monitors (unless it's meant for low bandwith) + * @see https://phabricator.wikimedia.org/T134498 + */ + $colorSpaces = [ self::SRGB_EXIF_COLOR_SPACE, '-' ]; + $profiles = [ self::SRGB_ICC_PROFILE_DESCRIPTION ]; + + // we'll also add TinyRGB profile to images lacking a profile, but + // only if they're not low quality (which are meant to save bandwith + // and we don't want to increase the filesize by adding a profile) + if ( isset( $params['quality'] ) && $params['quality'] > 30 ) { + $profiles[] = '-'; + } + + $this->swapICCProfile( + $params['dstPath'], + $colorSpaces, + $profiles, + realpath( __DIR__ ) . '/tinyrgb.icc' + ); + } + + return false; + } + + /** + * Swaps an embedded ICC profile for another, if found. + * Depends on exiftool, no-op if not installed. + * @param string $filepath File to be manipulated (will be overwritten) + * @param array $colorSpaces Only process files with this/these Color Space(s) + * @param array $oldProfileStrings Exact name(s) of color profile to look for + * (the one that will be replaced) + * @param string $profileFilepath ICC profile file to apply to the file + * @since 1.26 + * @return bool + */ + public function swapICCProfile( $filepath, array $colorSpaces, + array $oldProfileStrings, $profileFilepath + ) { + global $wgExiftool; + + if ( !$wgExiftool || !is_executable( $wgExiftool ) ) { + return false; + } + + $cmd = wfEscapeShellArg( $wgExiftool, + '-EXIF:ColorSpace', + '-ICC_Profile:ProfileDescription', + '-S', + '-T', + $filepath + ); + + $output = wfShellExecWithStderr( $cmd, $retval ); + + // Explode EXIF data into an array with [0 => Color Space, 1 => Device Model Desc] + $data = explode( "\t", trim( $output ) ); + + if ( $retval !== 0 ) { + return false; + } + + // Make a regex out of the source data to match it to an array of color + // spaces in a case-insensitive way + $colorSpaceRegex = '/'.preg_quote( $data[0], '/' ).'/i'; + if ( empty( preg_grep( $colorSpaceRegex, $colorSpaces ) ) ) { + // We can't establish that this file matches the color space, don't process it + return false; + } + + $profileRegex = '/'.preg_quote( $data[1], '/' ).'/i'; + if ( empty( preg_grep( $profileRegex, $oldProfileStrings ) ) ) { + // We can't establish that this file has the expected ICC profile, don't process it + return false; + } + + $cmd = wfEscapeShellArg( $wgExiftool, + '-overwrite_original', + '-icc_profile<=' . $profileFilepath, + $filepath + ); + + $output = wfShellExecWithStderr( $cmd, $retval ); + + if ( $retval !== 0 ) { + $this->logErrorForExternalProcess( $retval, $output, $cmd ); + + return false; + } + + return true; + } +} diff --git a/includes/media/PNG.php b/includes/media/PNG.php deleted file mode 100644 index 6748b26b09..0000000000 --- a/includes/media/PNG.php +++ /dev/null @@ -1,203 +0,0 @@ -getMessage() . "\n" ); - - return self::BROKEN_FILE; - } - - return serialize( $metadata ); - } - - /** - * @param File $image - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $image, $context = false ) { - $meta = $this->getCommonMetaArray( $image ); - if ( count( $meta ) === 0 ) { - return false; - } - - return $this->formatMetadataHelper( $meta, $context ); - } - - /** - * Get a file type independent array of metadata. - * - * @param File $image - * @return array The metadata array - */ - public function getCommonMetaArray( File $image ) { - $meta = $image->getMetadata(); - - if ( !$meta ) { - return []; - } - $meta = unserialize( $meta ); - if ( !isset( $meta['metadata'] ) ) { - return []; - } - unset( $meta['metadata']['_MW_PNG_VERSION'] ); - - return $meta['metadata']; - } - - /** - * @param File $image - * @return bool - */ - function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( $metadata['frameCount'] > 1 ) { - return true; - } - } - - return false; - } - - /** - * We do not support making APNG thumbnails, so always false - * @param File $image - * @return bool False - */ - function canAnimateThumbnail( $image ) { - return false; - } - - function getMetadataType( $image ) { - return 'parsed-png'; - } - - function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid png metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_PNG_VERSION'] ) - || $data['metadata']['_MW_PNG_VERSION'] != PNGMetadataExtractor::VERSION - ) { - wfDebug( __METHOD__ . " old but compatible png metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - /** - * @param File $image - * @return string - */ - function getLongDesc( $image ) { - global $wgLang; - $original = parent::getLongDesc( $image ); - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $image->getMetadata() ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || $metadata['frameCount'] <= 0 ) { - return $original; - } - - $info = []; - $info[] = $original; - - if ( $metadata['loopCount'] == 0 ) { - $info[] = wfMessage( 'file-info-png-looped' )->parse(); - } elseif ( $metadata['loopCount'] > 1 ) { - $info[] = wfMessage( 'file-info-png-repeat' )->numParams( $metadata['loopCount'] )->parse(); - } - - if ( $metadata['frameCount'] > 0 ) { - $info[] = wfMessage( 'file-info-png-frames' )->numParams( $metadata['frameCount'] )->parse(); - } - - if ( $metadata['duration'] ) { - $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); - } - - return $wgLang->commaList( $info ); - } - - /** - * Return the duration of an APNG file. - * - * Shown in the &query=imageinfo&iiprop=size api query. - * - * @param File $file - * @return float The duration of the file. - */ - public function getLength( $file ) { - $serMeta = $file->getMetadata(); - Wikimedia\suppressWarnings(); - $metadata = unserialize( $serMeta ); - Wikimedia\restoreWarnings(); - - if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { - return 0.0; - } else { - return (float)$metadata['duration']; - } - } - - // PNGs should be easy to support, but it will need some sharpening applied - // and another user test to check if the perceived quality change is noticeable - public function supportsBucketing() { - return false; - } -} diff --git a/includes/media/PNGHandler.php b/includes/media/PNGHandler.php new file mode 100644 index 0000000000..6748b26b09 --- /dev/null +++ b/includes/media/PNGHandler.php @@ -0,0 +1,203 @@ +getMessage() . "\n" ); + + return self::BROKEN_FILE; + } + + return serialize( $metadata ); + } + + /** + * @param File $image + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $image, $context = false ) { + $meta = $this->getCommonMetaArray( $image ); + if ( count( $meta ) === 0 ) { + return false; + } + + return $this->formatMetadataHelper( $meta, $context ); + } + + /** + * Get a file type independent array of metadata. + * + * @param File $image + * @return array The metadata array + */ + public function getCommonMetaArray( File $image ) { + $meta = $image->getMetadata(); + + if ( !$meta ) { + return []; + } + $meta = unserialize( $meta ); + if ( !isset( $meta['metadata'] ) ) { + return []; + } + unset( $meta['metadata']['_MW_PNG_VERSION'] ); + + return $meta['metadata']; + } + + /** + * @param File $image + * @return bool + */ + function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( $metadata['frameCount'] > 1 ) { + return true; + } + } + + return false; + } + + /** + * We do not support making APNG thumbnails, so always false + * @param File $image + * @return bool False + */ + function canAnimateThumbnail( $image ) { + return false; + } + + function getMetadataType( $image ) { + return 'parsed-png'; + } + + function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid png metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_PNG_VERSION'] ) + || $data['metadata']['_MW_PNG_VERSION'] != PNGMetadataExtractor::VERSION + ) { + wfDebug( __METHOD__ . " old but compatible png metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + /** + * @param File $image + * @return string + */ + function getLongDesc( $image ) { + global $wgLang; + $original = parent::getLongDesc( $image ); + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $image->getMetadata() ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || $metadata['frameCount'] <= 0 ) { + return $original; + } + + $info = []; + $info[] = $original; + + if ( $metadata['loopCount'] == 0 ) { + $info[] = wfMessage( 'file-info-png-looped' )->parse(); + } elseif ( $metadata['loopCount'] > 1 ) { + $info[] = wfMessage( 'file-info-png-repeat' )->numParams( $metadata['loopCount'] )->parse(); + } + + if ( $metadata['frameCount'] > 0 ) { + $info[] = wfMessage( 'file-info-png-frames' )->numParams( $metadata['frameCount'] )->parse(); + } + + if ( $metadata['duration'] ) { + $info[] = $wgLang->formatTimePeriod( $metadata['duration'] ); + } + + return $wgLang->commaList( $info ); + } + + /** + * Return the duration of an APNG file. + * + * Shown in the &query=imageinfo&iiprop=size api query. + * + * @param File $file + * @return float The duration of the file. + */ + public function getLength( $file ) { + $serMeta = $file->getMetadata(); + Wikimedia\suppressWarnings(); + $metadata = unserialize( $serMeta ); + Wikimedia\restoreWarnings(); + + if ( !$metadata || !isset( $metadata['duration'] ) || !$metadata['duration'] ) { + return 0.0; + } else { + return (float)$metadata['duration']; + } + } + + // PNGs should be easy to support, but it will need some sharpening applied + // and another user test to check if the perceived quality change is noticeable + public function supportsBucketing() { + return false; + } +} diff --git a/includes/media/SVG.php b/includes/media/SVG.php deleted file mode 100644 index 9085421af8..0000000000 --- a/includes/media/SVG.php +++ /dev/null @@ -1,593 +0,0 @@ - 'ImageWidth', - 'originalheight' => 'ImageLength', - 'description' => 'ImageDescription', - 'title' => 'ObjectName', - ]; - - function isEnabled() { - global $wgSVGConverters, $wgSVGConverter; - if ( !isset( $wgSVGConverters[$wgSVGConverter] ) ) { - wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering.\n" ); - - return false; - } else { - return true; - } - } - - public function mustRender( $file ) { - return true; - } - - function isVectorized( $file ) { - return true; - } - - /** - * @param File $file - * @return bool - */ - function isAnimatedImage( $file ) { - # @todo Detect animated SVGs - $metadata = $file->getMetadata(); - if ( $metadata ) { - $metadata = $this->unpackMetadata( $metadata ); - if ( isset( $metadata['animated'] ) ) { - return $metadata['animated']; - } - } - - return false; - } - - /** - * Which languages (systemLanguage attribute) is supported. - * - * @note This list is not guaranteed to be exhaustive. - * To avoid OOM errors, we only look at first bit of a file. - * Thus all languages on this list are present in the file, - * but its possible for the file to have a language not on - * this list. - * - * @param File $file - * @return array Array of language codes, or empty if no language switching supported. - */ - public function getAvailableLanguages( File $file ) { - $metadata = $file->getMetadata(); - $langList = []; - if ( $metadata ) { - $metadata = $this->unpackMetadata( $metadata ); - if ( isset( $metadata['translations'] ) ) { - foreach ( $metadata['translations'] as $lang => $langType ) { - if ( $langType === SVGReader::LANG_FULL_MATCH ) { - $langList[] = strtolower( $lang ); - } - } - } - } - return array_unique( $langList ); - } - - /** - * SVG's systemLanguage matching rules state: - * 'The `systemLanguage` attribute ... [e]valuates to "true" if one of the languages indicated - * by user preferences exactly equals one of the languages given in the value of this parameter, - * or if one of the languages indicated by user preferences exactly equals a prefix of one of - * the languages given in the value of this parameter such that the first tag character - * following the prefix is "-".' - * - * Return the first element of $svgLanguages that matches $userPreferredLanguage - * - * @see https://www.w3.org/TR/SVG/struct.html#SystemLanguageAttribute - * @param string $userPreferredLanguage - * @param array $svgLanguages - * @return string|null - */ - public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) { - foreach ( $svgLanguages as $svgLang ) { - if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) { - return $svgLang; - } - $trimmedSvgLang = $svgLang; - while ( strpos( $trimmedSvgLang, '-' ) !== false ) { - $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) ); - if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) { - return $svgLang; - } - } - } - return null; - } - - /** - * What language to render file in if none selected - * - * @param File $file Language code - * @return string - */ - public function getDefaultRenderLanguage( File $file ) { - return 'en'; - } - - /** - * We do not support making animated svg thumbnails - * @param File $file - * @return bool - */ - function canAnimateThumbnail( $file ) { - return false; - } - - /** - * @param File $image - * @param array &$params - * @return bool - */ - function normaliseParams( $image, &$params ) { - global $wgSVGMaxSize; - if ( !parent::normaliseParams( $image, $params ) ) { - return false; - } - # Don't make an image bigger than wgMaxSVGSize on the smaller side - if ( $params['physicalWidth'] <= $params['physicalHeight'] ) { - if ( $params['physicalWidth'] > $wgSVGMaxSize ) { - $srcWidth = $image->getWidth( $params['page'] ); - $srcHeight = $image->getHeight( $params['page'] ); - $params['physicalWidth'] = $wgSVGMaxSize; - $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $wgSVGMaxSize ); - } - } else { - if ( $params['physicalHeight'] > $wgSVGMaxSize ) { - $srcWidth = $image->getWidth( $params['page'] ); - $srcHeight = $image->getHeight( $params['page'] ); - $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $wgSVGMaxSize ); - $params['physicalHeight'] = $wgSVGMaxSize; - } - } - - return true; - } - - /** - * @param File $image - * @param string $dstPath - * @param string $dstUrl - * @param array $params - * @param int $flags - * @return bool|MediaTransformError|ThumbnailImage|TransformParameterError - */ - function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { - if ( !$this->normaliseParams( $image, $params ) ) { - return new TransformParameterError( $params ); - } - $clientWidth = $params['width']; - $clientHeight = $params['height']; - $physicalWidth = $params['physicalWidth']; - $physicalHeight = $params['physicalHeight']; - $lang = isset( $params['lang'] ) ? $params['lang'] : $this->getDefaultRenderLanguage( $image ); - - if ( $flags & self::TRANSFORM_LATER ) { - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } - - $metadata = $this->unpackMetadata( $image->getMetadata() ); - if ( isset( $metadata['error'] ) ) { // sanity check - $err = wfMessage( 'svg-long-error', $metadata['error']['message'] ); - - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err ); - } - - if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { - return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, - wfMessage( 'thumbnail_dest_directory' ) ); - } - - $srcPath = $image->getLocalRefPath(); - if ( $srcPath === false ) { // Failed to get local copy - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', - wfHostname(), $image->getName() ) ); - - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'filemissing' ) - ); - } - - // Make a temp dir with a symlink to the local copy in it. - // This plays well with rsvg-convert policy for external entities. - // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e - $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 ); - $lnPath = "$tmpDir/" . basename( $srcPath ); - $ok = mkdir( $tmpDir, 0771 ); - if ( !$ok ) { - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not create temporary directory %s', - wfHostname(), $tmpDir ) ); - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'thumbnail-temp-create' )->text() - ); - } - $ok = symlink( $srcPath, $lnPath ); - /** @noinspection PhpUnusedLocalVariableInspection */ - $cleaner = new ScopedCallback( function () use ( $tmpDir, $lnPath ) { - Wikimedia\suppressWarnings(); - unlink( $lnPath ); - rmdir( $tmpDir ); - Wikimedia\restoreWarnings(); - } ); - if ( !$ok ) { - wfDebugLog( 'thumbnail', - sprintf( 'Thumbnail failed on %s: could not link %s to %s', - wfHostname(), $lnPath, $srcPath ) ); - return new MediaTransformError( 'thumbnail_error', - $params['width'], $params['height'], - wfMessage( 'thumbnail-temp-create' ) - ); - } - - $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang ); - if ( $status === true ) { - return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); - } else { - return $status; // MediaTransformError - } - } - - /** - * Transform an SVG file to PNG - * This function can be called outside of thumbnail contexts - * @param string $srcPath - * @param string $dstPath - * @param string $width - * @param string $height - * @param bool|string $lang Language code of the language to render the SVG in - * @throws MWException - * @return bool|MediaTransformError - */ - public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) { - global $wgSVGConverters, $wgSVGConverter, $wgSVGConverterPath; - $err = false; - $retval = ''; - if ( isset( $wgSVGConverters[$wgSVGConverter] ) ) { - if ( is_array( $wgSVGConverters[$wgSVGConverter] ) ) { - // This is a PHP callable - $func = $wgSVGConverters[$wgSVGConverter][0]; - $args = array_merge( [ $srcPath, $dstPath, $width, $height, $lang ], - array_slice( $wgSVGConverters[$wgSVGConverter], 1 ) ); - if ( !is_callable( $func ) ) { - throw new MWException( "$func is not callable" ); - } - $err = call_user_func_array( $func, $args ); - $retval = (bool)$err; - } else { - // External command - $cmd = str_replace( - [ '$path/', '$width', '$height', '$input', '$output' ], - [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "", - intval( $width ), - intval( $height ), - wfEscapeShellArg( $srcPath ), - wfEscapeShellArg( $dstPath ) ], - $wgSVGConverters[$wgSVGConverter] - ); - - $env = []; - if ( $lang !== false ) { - $env['LANG'] = $lang; - } - - wfDebug( __METHOD__ . ": $cmd\n" ); - $err = wfShellExecWithStderr( $cmd, $retval, $env ); - } - } - $removed = $this->removeBadFile( $dstPath, $retval ); - if ( $retval != 0 || $removed ) { - $this->logErrorForExternalProcess( $retval, $err, $cmd ); - return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); - } - - return true; - } - - public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) { - $im = new Imagick( $srcPath ); - $im->setImageFormat( 'png' ); - $im->setBackgroundColor( 'transparent' ); - $im->setImageDepth( 8 ); - - if ( !$im->thumbnailImage( intval( $width ), intval( $height ), /* fit */ false ) ) { - return 'Could not resize image'; - } - if ( !$im->writeImage( $dstPath ) ) { - return "Could not write to $dstPath"; - } - } - - /** - * @param File|FSFile $file - * @param string $path Unused - * @param bool|array $metadata - * @return array - */ - function getImageSize( $file, $path, $metadata = false ) { - if ( $metadata === false && $file instanceof File ) { - $metadata = $file->getMetadata(); - } - $metadata = $this->unpackMetadata( $metadata ); - - if ( isset( $metadata['width'] ) && isset( $metadata['height'] ) ) { - return [ $metadata['width'], $metadata['height'], 'SVG', - "width=\"{$metadata['width']}\" height=\"{$metadata['height']}\"" ]; - } else { // error - return [ 0, 0, 'SVG', "width=\"0\" height=\"0\"" ]; - } - } - - function getThumbType( $ext, $mime, $params = null ) { - return [ 'png', 'image/png' ]; - } - - /** - * Subtitle for the image. Different from the base - * class so it can be denoted that SVG's have - * a "nominal" resolution, and not a fixed one, - * as well as so animation can be denoted. - * - * @param File $file - * @return string - */ - function getLongDesc( $file ) { - global $wgLang; - - $metadata = $this->unpackMetadata( $file->getMetadata() ); - if ( isset( $metadata['error'] ) ) { - return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text(); - } - - $size = $wgLang->formatSize( $file->getSize() ); - - if ( $this->isAnimatedImage( $file ) ) { - $msg = wfMessage( 'svg-long-desc-animated' ); - } else { - $msg = wfMessage( 'svg-long-desc' ); - } - - $msg->numParams( $file->getWidth(), $file->getHeight() )->params( $size ); - - return $msg->parse(); - } - - /** - * @param File|FSFile $file - * @param string $filename - * @return string Serialised metadata - */ - function getMetadata( $file, $filename ) { - $metadata = [ 'version' => self::SVG_METADATA_VERSION ]; - try { - $metadata += SVGMetadataExtractor::getMetadata( $filename ); - } catch ( Exception $e ) { // @todo SVG specific exceptions - // File not found, broken, etc. - $metadata['error'] = [ - 'message' => $e->getMessage(), - 'code' => $e->getCode() - ]; - wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); - } - - return serialize( $metadata ); - } - - function unpackMetadata( $metadata ) { - Wikimedia\suppressWarnings(); - $unser = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - if ( isset( $unser['version'] ) && $unser['version'] == self::SVG_METADATA_VERSION ) { - return $unser; - } else { - return false; - } - } - - function getMetadataType( $image ) { - return 'parsed-svg'; - } - - function isMetadataValid( $image, $metadata ) { - $meta = $this->unpackMetadata( $metadata ); - if ( $meta === false ) { - return self::METADATA_BAD; - } - if ( !isset( $meta['originalWidth'] ) ) { - // Old but compatible - return self::METADATA_COMPATIBLE; - } - - return self::METADATA_GOOD; - } - - protected function visibleMetadataFields() { - $fields = [ 'objectname', 'imagedescription' ]; - - return $fields; - } - - /** - * @param File $file - * @param bool|IContextSource $context Context to use (optional) - * @return array|bool - */ - function formatMetadata( $file, $context = false ) { - $result = [ - 'visible' => [], - 'collapsed' => [] - ]; - $metadata = $file->getMetadata(); - if ( !$metadata ) { - return false; - } - $metadata = $this->unpackMetadata( $metadata ); - if ( !$metadata || isset( $metadata['error'] ) ) { - return false; - } - - /* @todo Add a formatter - $format = new FormatSVG( $metadata ); - $formatted = $format->getFormattedData(); - */ - - // Sort fields into visible and collapsed - $visibleFields = $this->visibleMetadataFields(); - - $showMeta = false; - foreach ( $metadata as $name => $value ) { - $tag = strtolower( $name ); - if ( isset( self::$metaConversion[$tag] ) ) { - $tag = strtolower( self::$metaConversion[$tag] ); - } else { - // Do not output other metadata not in list - continue; - } - $showMeta = true; - self::addMeta( $result, - in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed', - 'exif', - $tag, - $value - ); - } - - return $showMeta ? $result : false; - } - - /** - * @param string $name Parameter name - * @param mixed $value Parameter value - * @return bool Validity - */ - public function validateParam( $name, $value ) { - if ( in_array( $name, [ 'width', 'height' ] ) ) { - // Reject negative heights, widths - return ( $value > 0 ); - } elseif ( $name == 'lang' ) { - // Validate $code - if ( $value === '' || !Language::isValidCode( $value ) ) { - return false; - } - - return true; - } - - // Only lang, width and height are acceptable keys - return false; - } - - /** - * @param array $params Name=>value pairs of parameters - * @return string Filename to use - */ - public function makeParamString( $params ) { - $lang = ''; - if ( isset( $params['lang'] ) && $params['lang'] !== 'en' ) { - $lang = 'lang' . strtolower( $params['lang'] ) . '-'; - } - if ( !isset( $params['width'] ) ) { - return false; - } - - return "$lang{$params['width']}px"; - } - - public function parseParamString( $str ) { - $m = false; - if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/i', $str, $m ) ) { - return [ 'width' => array_pop( $m ), 'lang' => $m[1] ]; - } elseif ( preg_match( '/^(\d+)px$/', $str, $m ) ) { - return [ 'width' => $m[1], 'lang' => 'en' ]; - } else { - return false; - } - } - - public function getParamMap() { - return [ 'img_lang' => 'lang', 'img_width' => 'width' ]; - } - - /** - * @param array $params - * @return array - */ - function getScriptParams( $params ) { - $scriptParams = [ 'width' => $params['width'] ]; - if ( isset( $params['lang'] ) ) { - $scriptParams['lang'] = $params['lang']; - } - - return $scriptParams; - } - - public function getCommonMetaArray( File $file ) { - $metadata = $file->getMetadata(); - if ( !$metadata ) { - return []; - } - $metadata = $this->unpackMetadata( $metadata ); - if ( !$metadata || isset( $metadata['error'] ) ) { - return []; - } - $stdMetadata = []; - foreach ( $metadata as $name => $value ) { - $tag = strtolower( $name ); - if ( $tag === 'originalwidth' || $tag === 'originalheight' ) { - // Skip these. In the exif metadata stuff, it is assumed these - // are measured in px, which is not the case here. - continue; - } - if ( isset( self::$metaConversion[$tag] ) ) { - $tag = self::$metaConversion[$tag]; - $stdMetadata[$tag] = $value; - } - } - - return $stdMetadata; - } -} diff --git a/includes/media/SvgHandler.php b/includes/media/SvgHandler.php new file mode 100644 index 0000000000..9085421af8 --- /dev/null +++ b/includes/media/SvgHandler.php @@ -0,0 +1,593 @@ + 'ImageWidth', + 'originalheight' => 'ImageLength', + 'description' => 'ImageDescription', + 'title' => 'ObjectName', + ]; + + function isEnabled() { + global $wgSVGConverters, $wgSVGConverter; + if ( !isset( $wgSVGConverters[$wgSVGConverter] ) ) { + wfDebug( "\$wgSVGConverter is invalid, disabling SVG rendering.\n" ); + + return false; + } else { + return true; + } + } + + public function mustRender( $file ) { + return true; + } + + function isVectorized( $file ) { + return true; + } + + /** + * @param File $file + * @return bool + */ + function isAnimatedImage( $file ) { + # @todo Detect animated SVGs + $metadata = $file->getMetadata(); + if ( $metadata ) { + $metadata = $this->unpackMetadata( $metadata ); + if ( isset( $metadata['animated'] ) ) { + return $metadata['animated']; + } + } + + return false; + } + + /** + * Which languages (systemLanguage attribute) is supported. + * + * @note This list is not guaranteed to be exhaustive. + * To avoid OOM errors, we only look at first bit of a file. + * Thus all languages on this list are present in the file, + * but its possible for the file to have a language not on + * this list. + * + * @param File $file + * @return array Array of language codes, or empty if no language switching supported. + */ + public function getAvailableLanguages( File $file ) { + $metadata = $file->getMetadata(); + $langList = []; + if ( $metadata ) { + $metadata = $this->unpackMetadata( $metadata ); + if ( isset( $metadata['translations'] ) ) { + foreach ( $metadata['translations'] as $lang => $langType ) { + if ( $langType === SVGReader::LANG_FULL_MATCH ) { + $langList[] = strtolower( $lang ); + } + } + } + } + return array_unique( $langList ); + } + + /** + * SVG's systemLanguage matching rules state: + * 'The `systemLanguage` attribute ... [e]valuates to "true" if one of the languages indicated + * by user preferences exactly equals one of the languages given in the value of this parameter, + * or if one of the languages indicated by user preferences exactly equals a prefix of one of + * the languages given in the value of this parameter such that the first tag character + * following the prefix is "-".' + * + * Return the first element of $svgLanguages that matches $userPreferredLanguage + * + * @see https://www.w3.org/TR/SVG/struct.html#SystemLanguageAttribute + * @param string $userPreferredLanguage + * @param array $svgLanguages + * @return string|null + */ + public function getMatchedLanguage( $userPreferredLanguage, array $svgLanguages ) { + foreach ( $svgLanguages as $svgLang ) { + if ( strcasecmp( $svgLang, $userPreferredLanguage ) === 0 ) { + return $svgLang; + } + $trimmedSvgLang = $svgLang; + while ( strpos( $trimmedSvgLang, '-' ) !== false ) { + $trimmedSvgLang = substr( $trimmedSvgLang, 0, strrpos( $trimmedSvgLang, '-' ) ); + if ( strcasecmp( $trimmedSvgLang, $userPreferredLanguage ) === 0 ) { + return $svgLang; + } + } + } + return null; + } + + /** + * What language to render file in if none selected + * + * @param File $file Language code + * @return string + */ + public function getDefaultRenderLanguage( File $file ) { + return 'en'; + } + + /** + * We do not support making animated svg thumbnails + * @param File $file + * @return bool + */ + function canAnimateThumbnail( $file ) { + return false; + } + + /** + * @param File $image + * @param array &$params + * @return bool + */ + function normaliseParams( $image, &$params ) { + global $wgSVGMaxSize; + if ( !parent::normaliseParams( $image, $params ) ) { + return false; + } + # Don't make an image bigger than wgMaxSVGSize on the smaller side + if ( $params['physicalWidth'] <= $params['physicalHeight'] ) { + if ( $params['physicalWidth'] > $wgSVGMaxSize ) { + $srcWidth = $image->getWidth( $params['page'] ); + $srcHeight = $image->getHeight( $params['page'] ); + $params['physicalWidth'] = $wgSVGMaxSize; + $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $wgSVGMaxSize ); + } + } else { + if ( $params['physicalHeight'] > $wgSVGMaxSize ) { + $srcWidth = $image->getWidth( $params['page'] ); + $srcHeight = $image->getHeight( $params['page'] ); + $params['physicalWidth'] = File::scaleHeight( $srcHeight, $srcWidth, $wgSVGMaxSize ); + $params['physicalHeight'] = $wgSVGMaxSize; + } + } + + return true; + } + + /** + * @param File $image + * @param string $dstPath + * @param string $dstUrl + * @param array $params + * @param int $flags + * @return bool|MediaTransformError|ThumbnailImage|TransformParameterError + */ + function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { + if ( !$this->normaliseParams( $image, $params ) ) { + return new TransformParameterError( $params ); + } + $clientWidth = $params['width']; + $clientHeight = $params['height']; + $physicalWidth = $params['physicalWidth']; + $physicalHeight = $params['physicalHeight']; + $lang = isset( $params['lang'] ) ? $params['lang'] : $this->getDefaultRenderLanguage( $image ); + + if ( $flags & self::TRANSFORM_LATER ) { + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } + + $metadata = $this->unpackMetadata( $image->getMetadata() ); + if ( isset( $metadata['error'] ) ) { // sanity check + $err = wfMessage( 'svg-long-error', $metadata['error']['message'] ); + + return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, $err ); + } + + if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { + return new MediaTransformError( 'thumbnail_error', $clientWidth, $clientHeight, + wfMessage( 'thumbnail_dest_directory' ) ); + } + + $srcPath = $image->getLocalRefPath(); + if ( $srcPath === false ) { // Failed to get local copy + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', + wfHostname(), $image->getName() ) ); + + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'filemissing' ) + ); + } + + // Make a temp dir with a symlink to the local copy in it. + // This plays well with rsvg-convert policy for external entities. + // https://git.gnome.org/browse/librsvg/commit/?id=f01aded72c38f0e18bc7ff67dee800e380251c8e + $tmpDir = wfTempDir() . '/svg_' . wfRandomString( 24 ); + $lnPath = "$tmpDir/" . basename( $srcPath ); + $ok = mkdir( $tmpDir, 0771 ); + if ( !$ok ) { + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not create temporary directory %s', + wfHostname(), $tmpDir ) ); + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'thumbnail-temp-create' )->text() + ); + } + $ok = symlink( $srcPath, $lnPath ); + /** @noinspection PhpUnusedLocalVariableInspection */ + $cleaner = new ScopedCallback( function () use ( $tmpDir, $lnPath ) { + Wikimedia\suppressWarnings(); + unlink( $lnPath ); + rmdir( $tmpDir ); + Wikimedia\restoreWarnings(); + } ); + if ( !$ok ) { + wfDebugLog( 'thumbnail', + sprintf( 'Thumbnail failed on %s: could not link %s to %s', + wfHostname(), $lnPath, $srcPath ) ); + return new MediaTransformError( 'thumbnail_error', + $params['width'], $params['height'], + wfMessage( 'thumbnail-temp-create' ) + ); + } + + $status = $this->rasterize( $lnPath, $dstPath, $physicalWidth, $physicalHeight, $lang ); + if ( $status === true ) { + return new ThumbnailImage( $image, $dstUrl, $dstPath, $params ); + } else { + return $status; // MediaTransformError + } + } + + /** + * Transform an SVG file to PNG + * This function can be called outside of thumbnail contexts + * @param string $srcPath + * @param string $dstPath + * @param string $width + * @param string $height + * @param bool|string $lang Language code of the language to render the SVG in + * @throws MWException + * @return bool|MediaTransformError + */ + public function rasterize( $srcPath, $dstPath, $width, $height, $lang = false ) { + global $wgSVGConverters, $wgSVGConverter, $wgSVGConverterPath; + $err = false; + $retval = ''; + if ( isset( $wgSVGConverters[$wgSVGConverter] ) ) { + if ( is_array( $wgSVGConverters[$wgSVGConverter] ) ) { + // This is a PHP callable + $func = $wgSVGConverters[$wgSVGConverter][0]; + $args = array_merge( [ $srcPath, $dstPath, $width, $height, $lang ], + array_slice( $wgSVGConverters[$wgSVGConverter], 1 ) ); + if ( !is_callable( $func ) ) { + throw new MWException( "$func is not callable" ); + } + $err = call_user_func_array( $func, $args ); + $retval = (bool)$err; + } else { + // External command + $cmd = str_replace( + [ '$path/', '$width', '$height', '$input', '$output' ], + [ $wgSVGConverterPath ? wfEscapeShellArg( "$wgSVGConverterPath/" ) : "", + intval( $width ), + intval( $height ), + wfEscapeShellArg( $srcPath ), + wfEscapeShellArg( $dstPath ) ], + $wgSVGConverters[$wgSVGConverter] + ); + + $env = []; + if ( $lang !== false ) { + $env['LANG'] = $lang; + } + + wfDebug( __METHOD__ . ": $cmd\n" ); + $err = wfShellExecWithStderr( $cmd, $retval, $env ); + } + } + $removed = $this->removeBadFile( $dstPath, $retval ); + if ( $retval != 0 || $removed ) { + $this->logErrorForExternalProcess( $retval, $err, $cmd ); + return new MediaTransformError( 'thumbnail_error', $width, $height, $err ); + } + + return true; + } + + public static function rasterizeImagickExt( $srcPath, $dstPath, $width, $height ) { + $im = new Imagick( $srcPath ); + $im->setImageFormat( 'png' ); + $im->setBackgroundColor( 'transparent' ); + $im->setImageDepth( 8 ); + + if ( !$im->thumbnailImage( intval( $width ), intval( $height ), /* fit */ false ) ) { + return 'Could not resize image'; + } + if ( !$im->writeImage( $dstPath ) ) { + return "Could not write to $dstPath"; + } + } + + /** + * @param File|FSFile $file + * @param string $path Unused + * @param bool|array $metadata + * @return array + */ + function getImageSize( $file, $path, $metadata = false ) { + if ( $metadata === false && $file instanceof File ) { + $metadata = $file->getMetadata(); + } + $metadata = $this->unpackMetadata( $metadata ); + + if ( isset( $metadata['width'] ) && isset( $metadata['height'] ) ) { + return [ $metadata['width'], $metadata['height'], 'SVG', + "width=\"{$metadata['width']}\" height=\"{$metadata['height']}\"" ]; + } else { // error + return [ 0, 0, 'SVG', "width=\"0\" height=\"0\"" ]; + } + } + + function getThumbType( $ext, $mime, $params = null ) { + return [ 'png', 'image/png' ]; + } + + /** + * Subtitle for the image. Different from the base + * class so it can be denoted that SVG's have + * a "nominal" resolution, and not a fixed one, + * as well as so animation can be denoted. + * + * @param File $file + * @return string + */ + function getLongDesc( $file ) { + global $wgLang; + + $metadata = $this->unpackMetadata( $file->getMetadata() ); + if ( isset( $metadata['error'] ) ) { + return wfMessage( 'svg-long-error', $metadata['error']['message'] )->text(); + } + + $size = $wgLang->formatSize( $file->getSize() ); + + if ( $this->isAnimatedImage( $file ) ) { + $msg = wfMessage( 'svg-long-desc-animated' ); + } else { + $msg = wfMessage( 'svg-long-desc' ); + } + + $msg->numParams( $file->getWidth(), $file->getHeight() )->params( $size ); + + return $msg->parse(); + } + + /** + * @param File|FSFile $file + * @param string $filename + * @return string Serialised metadata + */ + function getMetadata( $file, $filename ) { + $metadata = [ 'version' => self::SVG_METADATA_VERSION ]; + try { + $metadata += SVGMetadataExtractor::getMetadata( $filename ); + } catch ( Exception $e ) { // @todo SVG specific exceptions + // File not found, broken, etc. + $metadata['error'] = [ + 'message' => $e->getMessage(), + 'code' => $e->getCode() + ]; + wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); + } + + return serialize( $metadata ); + } + + function unpackMetadata( $metadata ) { + Wikimedia\suppressWarnings(); + $unser = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + if ( isset( $unser['version'] ) && $unser['version'] == self::SVG_METADATA_VERSION ) { + return $unser; + } else { + return false; + } + } + + function getMetadataType( $image ) { + return 'parsed-svg'; + } + + function isMetadataValid( $image, $metadata ) { + $meta = $this->unpackMetadata( $metadata ); + if ( $meta === false ) { + return self::METADATA_BAD; + } + if ( !isset( $meta['originalWidth'] ) ) { + // Old but compatible + return self::METADATA_COMPATIBLE; + } + + return self::METADATA_GOOD; + } + + protected function visibleMetadataFields() { + $fields = [ 'objectname', 'imagedescription' ]; + + return $fields; + } + + /** + * @param File $file + * @param bool|IContextSource $context Context to use (optional) + * @return array|bool + */ + function formatMetadata( $file, $context = false ) { + $result = [ + 'visible' => [], + 'collapsed' => [] + ]; + $metadata = $file->getMetadata(); + if ( !$metadata ) { + return false; + } + $metadata = $this->unpackMetadata( $metadata ); + if ( !$metadata || isset( $metadata['error'] ) ) { + return false; + } + + /* @todo Add a formatter + $format = new FormatSVG( $metadata ); + $formatted = $format->getFormattedData(); + */ + + // Sort fields into visible and collapsed + $visibleFields = $this->visibleMetadataFields(); + + $showMeta = false; + foreach ( $metadata as $name => $value ) { + $tag = strtolower( $name ); + if ( isset( self::$metaConversion[$tag] ) ) { + $tag = strtolower( self::$metaConversion[$tag] ); + } else { + // Do not output other metadata not in list + continue; + } + $showMeta = true; + self::addMeta( $result, + in_array( $tag, $visibleFields ) ? 'visible' : 'collapsed', + 'exif', + $tag, + $value + ); + } + + return $showMeta ? $result : false; + } + + /** + * @param string $name Parameter name + * @param mixed $value Parameter value + * @return bool Validity + */ + public function validateParam( $name, $value ) { + if ( in_array( $name, [ 'width', 'height' ] ) ) { + // Reject negative heights, widths + return ( $value > 0 ); + } elseif ( $name == 'lang' ) { + // Validate $code + if ( $value === '' || !Language::isValidCode( $value ) ) { + return false; + } + + return true; + } + + // Only lang, width and height are acceptable keys + return false; + } + + /** + * @param array $params Name=>value pairs of parameters + * @return string Filename to use + */ + public function makeParamString( $params ) { + $lang = ''; + if ( isset( $params['lang'] ) && $params['lang'] !== 'en' ) { + $lang = 'lang' . strtolower( $params['lang'] ) . '-'; + } + if ( !isset( $params['width'] ) ) { + return false; + } + + return "$lang{$params['width']}px"; + } + + public function parseParamString( $str ) { + $m = false; + if ( preg_match( '/^lang([a-z]+(?:-[a-z]+)*)-(\d+)px$/i', $str, $m ) ) { + return [ 'width' => array_pop( $m ), 'lang' => $m[1] ]; + } elseif ( preg_match( '/^(\d+)px$/', $str, $m ) ) { + return [ 'width' => $m[1], 'lang' => 'en' ]; + } else { + return false; + } + } + + public function getParamMap() { + return [ 'img_lang' => 'lang', 'img_width' => 'width' ]; + } + + /** + * @param array $params + * @return array + */ + function getScriptParams( $params ) { + $scriptParams = [ 'width' => $params['width'] ]; + if ( isset( $params['lang'] ) ) { + $scriptParams['lang'] = $params['lang']; + } + + return $scriptParams; + } + + public function getCommonMetaArray( File $file ) { + $metadata = $file->getMetadata(); + if ( !$metadata ) { + return []; + } + $metadata = $this->unpackMetadata( $metadata ); + if ( !$metadata || isset( $metadata['error'] ) ) { + return []; + } + $stdMetadata = []; + foreach ( $metadata as $name => $value ) { + $tag = strtolower( $name ); + if ( $tag === 'originalwidth' || $tag === 'originalheight' ) { + // Skip these. In the exif metadata stuff, it is assumed these + // are measured in px, which is not the case here. + continue; + } + if ( isset( self::$metaConversion[$tag] ) ) { + $tag = self::$metaConversion[$tag]; + $stdMetadata[$tag] = $value; + } + } + + return $stdMetadata; + } +} diff --git a/includes/media/Tiff.php b/includes/media/Tiff.php deleted file mode 100644 index f0f4cdad6c..0000000000 --- a/includes/media/Tiff.php +++ /dev/null @@ -1,107 +0,0 @@ -getRepo() instanceof ForeignAPIRepo; - } - - /** - * Browsers don't support TIFF inline generally... - * For inline display, we need to convert to PNG. - * - * @param File $file - * @return bool - */ - public function mustRender( $file ) { - return true; - } - - /** - * @param string $ext - * @param string $mime - * @param array $params - * @return bool - */ - function getThumbType( $ext, $mime, $params = null ) { - global $wgTiffThumbnailType; - - return $wgTiffThumbnailType; - } - - /** - * @param File|FSFile $image - * @param string $filename - * @throws MWException - * @return string - */ - function getMetadata( $image, $filename ) { - global $wgShowEXIF; - - if ( $wgShowEXIF ) { - try { - $meta = BitmapMetadataHandler::Tiff( $filename ); - if ( !is_array( $meta ) ) { - // This should never happen, but doesn't hurt to be paranoid. - throw new MWException( 'Metadata array is not an array' ); - } - $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version(); - - return serialize( $meta ); - } catch ( Exception $e ) { - // BitmapMetadataHandler throws an exception in certain exceptional - // cases like if file does not exist. - wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); - - return ExifBitmapHandler::BROKEN_FILE; - } - } else { - return ''; - } - } - - public function isExpensiveToThumbnail( $file ) { - return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT; - } -} diff --git a/includes/media/TiffHandler.php b/includes/media/TiffHandler.php new file mode 100644 index 0000000000..f0f4cdad6c --- /dev/null +++ b/includes/media/TiffHandler.php @@ -0,0 +1,107 @@ +getRepo() instanceof ForeignAPIRepo; + } + + /** + * Browsers don't support TIFF inline generally... + * For inline display, we need to convert to PNG. + * + * @param File $file + * @return bool + */ + public function mustRender( $file ) { + return true; + } + + /** + * @param string $ext + * @param string $mime + * @param array $params + * @return bool + */ + function getThumbType( $ext, $mime, $params = null ) { + global $wgTiffThumbnailType; + + return $wgTiffThumbnailType; + } + + /** + * @param File|FSFile $image + * @param string $filename + * @throws MWException + * @return string + */ + function getMetadata( $image, $filename ) { + global $wgShowEXIF; + + if ( $wgShowEXIF ) { + try { + $meta = BitmapMetadataHandler::Tiff( $filename ); + if ( !is_array( $meta ) ) { + // This should never happen, but doesn't hurt to be paranoid. + throw new MWException( 'Metadata array is not an array' ); + } + $meta['MEDIAWIKI_EXIF_VERSION'] = Exif::version(); + + return serialize( $meta ); + } catch ( Exception $e ) { + // BitmapMetadataHandler throws an exception in certain exceptional + // cases like if file does not exist. + wfDebug( __METHOD__ . ': ' . $e->getMessage() . "\n" ); + + return ExifBitmapHandler::BROKEN_FILE; + } + } else { + return ''; + } + } + + public function isExpensiveToThumbnail( $file ) { + return $file->getSize() > static::EXPENSIVE_SIZE_LIMIT; + } +} diff --git a/includes/media/WebP.php b/includes/media/WebP.php deleted file mode 100644 index 295a9785b5..0000000000 --- a/includes/media/WebP.php +++ /dev/null @@ -1,309 +0,0 @@ - - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along - * with this program; if not, write to the Free Software Foundation, Inc., - * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * http://www.gnu.org/copyleft/gpl.html - * - * @file - * @ingroup Media - */ - -/** - * Handler for Google's WebP format - * - * @ingroup Media - */ -class WebPHandler extends BitmapHandler { - const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata. - /** - * @var int Minimum chunk header size to be able to read all header types - */ - const MINIMUM_CHUNK_HEADER_LENGTH = 18; - /** - * @var int version of the metadata stored in db records - */ - const _MW_WEBP_VERSION = 1; - - const VP8X_ICC = 32; - const VP8X_ALPHA = 16; - const VP8X_EXIF = 8; - const VP8X_XMP = 4; - const VP8X_ANIM = 2; - - public function getMetadata( $image, $filename ) { - $parsedWebPData = self::extractMetadata( $filename ); - if ( !$parsedWebPData ) { - return self::BROKEN_FILE; - } - - $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION; - return serialize( $parsedWebPData ); - } - - public function getMetadataType( $image ) { - return 'parsed-webp'; - } - - public function isMetadataValid( $image, $metadata ) { - if ( $metadata === self::BROKEN_FILE ) { - // Do not repetitivly regenerate metadata on broken file. - return self::METADATA_GOOD; - } - - Wikimedia\suppressWarnings(); - $data = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( !$data || !is_array( $data ) ) { - wfDebug( __METHOD__ . " invalid WebP metadata\n" ); - - return self::METADATA_BAD; - } - - if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] ) - || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION - ) { - wfDebug( __METHOD__ . " old but compatible WebP metadata\n" ); - - return self::METADATA_COMPATIBLE; - } - return self::METADATA_GOOD; - } - - /** - * Extracts the image size and WebP type from a file - * - * @param string $filename - * @return array|bool Header data array with entries 'compression', 'width' and 'height', - * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if - * file is not a valid WebP file. - */ - public static function extractMetadata( $filename ) { - wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename\n" ); - - $info = RiffExtractor::findChunksFromFile( $filename, 100 ); - if ( $info === false ) { - wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file\n" ); - return false; - } - - if ( $info['fourCC'] != 'WEBP' ) { - wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' . - bin2hex( $info['fourCC'] ) . " \n" ); - return false; - } - - $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename ); - if ( !$metadata ) { - wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found\n" ); - return false; - } - - return $metadata; - } - - /** - * Extracts the image size and WebP type from a file based on the chunk list - * @param array $chunks Chunks as extracted by RiffExtractor - * @param string $filename - * @return array Header data array with entries 'compression', 'width' and 'height', where - * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown' - */ - public static function extractMetadataFromChunks( $chunks, $filename ) { - $vp8Info = []; - - foreach ( $chunks as $chunk ) { - if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) { - // Not a chunk containing interesting metadata - continue; - } - - $chunkHeader = file_get_contents( $filename, false, null, - $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH ); - wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}\n" ); - - switch ( $chunk['fourCC'] ) { - case 'VP8 ': - return array_merge( $vp8Info, - self::decodeLossyChunkHeader( $chunkHeader ) ); - case 'VP8L': - return array_merge( $vp8Info, - self::decodeLosslessChunkHeader( $chunkHeader ) ); - case 'VP8X': - $vp8Info = array_merge( $vp8Info, - self::decodeExtendedChunkHeader( $chunkHeader ) ); - // Continue looking for other chunks to improve the metadata - break; - } - } - return $vp8Info; - } - - /** - * Decodes a lossy chunk header - * @param string $header First few bytes of the header, expected to be at least 18 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - protected static function decodeLossyChunkHeader( $header ) { - // Bytes 0-3 are 'VP8 ' - // Bytes 4-7 are the VP8 stream size - // Bytes 8-10 are the frame tag - // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code - $syncCode = substr( $header, 11, 3 ); - if ( $syncCode != "\x9D\x01\x2A" ) { - wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' . - bin2hex( $syncCode ) . "\n" ); - return []; - } - // Bytes 14-17 are image size - $imageSize = unpack( 'v2', substr( $header, 14, 4 ) ); - // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here - return [ - 'compression' => 'lossy', - 'width' => $imageSize[1] & 0x3FFF, - 'height' => $imageSize[2] & 0x3FFF - ]; - } - - /** - * Decodes a lossless chunk header - * @param string $header First few bytes of the header, expected to be at least 13 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - public static function decodeLosslessChunkHeader( $header ) { - // Bytes 0-3 are 'VP8L' - // Bytes 4-7 are chunk stream size - // Byte 8 is 0x2F called the signature - if ( $header{8} != "\x2F" ) { - wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' . - bin2hex( $header{8} ) . "\n" ); - return []; - } - // Bytes 9-12 contain the image size - // Bits 0-13 are width-1; bits 15-27 are height-1 - $imageSize = unpack( 'C4', substr( $header, 9, 4 ) ); - return [ - 'compression' => 'lossless', - 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1, - 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) | - ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1 - ]; - } - - /** - * Decodes an extended chunk header - * @param string $header First few bytes of the header, expected to be at least 18 bytes long - * @return bool|array See WebPHandler::decodeHeader - */ - public static function decodeExtendedChunkHeader( $header ) { - // Bytes 0-3 are 'VP8X' - // Byte 4-7 are chunk length - // Byte 8-11 are a flag bytes - $flags = unpack( 'c', substr( $header, 8, 1 ) ); - - // Byte 12-17 are image size (24 bits) - $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" ); - $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" ); - - return [ - 'compression' => 'unknown', - 'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM, - 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA, - 'width' => ( $width[1] & 0xFFFFFF ) + 1, - 'height' => ( $height[1] & 0xFFFFFF ) + 1 - ]; - } - - public function getImageSize( $file, $path, $metadata = false ) { - if ( $file === null ) { - $metadata = self::getMetadata( $file, $path ); - } - if ( $metadata === false && $file instanceof File ) { - $metadata = $file->getMetadata(); - } - - Wikimedia\suppressWarnings(); - $metadata = unserialize( $metadata ); - Wikimedia\restoreWarnings(); - - if ( $metadata == false ) { - return false; - } - return [ $metadata['width'], $metadata['height'] ]; - } - - /** - * @param File $file - * @return bool True, not all browsers support WebP - */ - public function mustRender( $file ) { - return true; - } - - /** - * @param File $file - * @return bool False if we are unable to render this image - */ - public function canRender( $file ) { - if ( self::isAnimatedImage( $file ) ) { - return false; - } - return true; - } - - /** - * @param File $image - * @return bool - */ - public function isAnimatedImage( $image ) { - $ser = $image->getMetadata(); - if ( $ser ) { - $metadata = unserialize( $ser ); - if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) { - return true; - } - } - - return false; - } - - public function canAnimateThumbnail( $file ) { - return false; - } - - /** - * Render files as PNG - * - * @param string $ext - * @param string $mime - * @param array|null $params - * @return array - */ - public function getThumbType( $ext, $mime, $params = null ) { - return [ 'png', 'image/png' ]; - } - - /** - * Must use "im" for XCF - * - * @param string $dstPath - * @param bool $checkDstPath - * @return string - */ - protected function getScalerType( $dstPath, $checkDstPath = true ) { - return 'im'; - } -} diff --git a/includes/media/WebPHandler.php b/includes/media/WebPHandler.php new file mode 100644 index 0000000000..295a9785b5 --- /dev/null +++ b/includes/media/WebPHandler.php @@ -0,0 +1,309 @@ + + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * http://www.gnu.org/copyleft/gpl.html + * + * @file + * @ingroup Media + */ + +/** + * Handler for Google's WebP format + * + * @ingroup Media + */ +class WebPHandler extends BitmapHandler { + const BROKEN_FILE = '0'; // value to store in img_metadata if error extracting metadata. + /** + * @var int Minimum chunk header size to be able to read all header types + */ + const MINIMUM_CHUNK_HEADER_LENGTH = 18; + /** + * @var int version of the metadata stored in db records + */ + const _MW_WEBP_VERSION = 1; + + const VP8X_ICC = 32; + const VP8X_ALPHA = 16; + const VP8X_EXIF = 8; + const VP8X_XMP = 4; + const VP8X_ANIM = 2; + + public function getMetadata( $image, $filename ) { + $parsedWebPData = self::extractMetadata( $filename ); + if ( !$parsedWebPData ) { + return self::BROKEN_FILE; + } + + $parsedWebPData['metadata']['_MW_WEBP_VERSION'] = self::_MW_WEBP_VERSION; + return serialize( $parsedWebPData ); + } + + public function getMetadataType( $image ) { + return 'parsed-webp'; + } + + public function isMetadataValid( $image, $metadata ) { + if ( $metadata === self::BROKEN_FILE ) { + // Do not repetitivly regenerate metadata on broken file. + return self::METADATA_GOOD; + } + + Wikimedia\suppressWarnings(); + $data = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( !$data || !is_array( $data ) ) { + wfDebug( __METHOD__ . " invalid WebP metadata\n" ); + + return self::METADATA_BAD; + } + + if ( !isset( $data['metadata']['_MW_WEBP_VERSION'] ) + || $data['metadata']['_MW_WEBP_VERSION'] != self::_MW_WEBP_VERSION + ) { + wfDebug( __METHOD__ . " old but compatible WebP metadata\n" ); + + return self::METADATA_COMPATIBLE; + } + return self::METADATA_GOOD; + } + + /** + * Extracts the image size and WebP type from a file + * + * @param string $filename + * @return array|bool Header data array with entries 'compression', 'width' and 'height', + * where 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown'. False if + * file is not a valid WebP file. + */ + public static function extractMetadata( $filename ) { + wfDebugLog( 'WebP', __METHOD__ . ": Extracting metadata from $filename\n" ); + + $info = RiffExtractor::findChunksFromFile( $filename, 100 ); + if ( $info === false ) { + wfDebugLog( 'WebP', __METHOD__ . ": Not a valid RIFF file\n" ); + return false; + } + + if ( $info['fourCC'] != 'WEBP' ) { + wfDebugLog( 'WebP', __METHOD__ . ': FourCC was not WEBP: ' . + bin2hex( $info['fourCC'] ) . " \n" ); + return false; + } + + $metadata = self::extractMetadataFromChunks( $info['chunks'], $filename ); + if ( !$metadata ) { + wfDebugLog( 'WebP', __METHOD__ . ": No VP8 chunks found\n" ); + return false; + } + + return $metadata; + } + + /** + * Extracts the image size and WebP type from a file based on the chunk list + * @param array $chunks Chunks as extracted by RiffExtractor + * @param string $filename + * @return array Header data array with entries 'compression', 'width' and 'height', where + * 'compression' can be 'lossy', 'lossless', 'animated' or 'unknown' + */ + public static function extractMetadataFromChunks( $chunks, $filename ) { + $vp8Info = []; + + foreach ( $chunks as $chunk ) { + if ( !in_array( $chunk['fourCC'], [ 'VP8 ', 'VP8L', 'VP8X' ] ) ) { + // Not a chunk containing interesting metadata + continue; + } + + $chunkHeader = file_get_contents( $filename, false, null, + $chunk['start'], self::MINIMUM_CHUNK_HEADER_LENGTH ); + wfDebugLog( 'WebP', __METHOD__ . ": {$chunk['fourCC']}\n" ); + + switch ( $chunk['fourCC'] ) { + case 'VP8 ': + return array_merge( $vp8Info, + self::decodeLossyChunkHeader( $chunkHeader ) ); + case 'VP8L': + return array_merge( $vp8Info, + self::decodeLosslessChunkHeader( $chunkHeader ) ); + case 'VP8X': + $vp8Info = array_merge( $vp8Info, + self::decodeExtendedChunkHeader( $chunkHeader ) ); + // Continue looking for other chunks to improve the metadata + break; + } + } + return $vp8Info; + } + + /** + * Decodes a lossy chunk header + * @param string $header First few bytes of the header, expected to be at least 18 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + protected static function decodeLossyChunkHeader( $header ) { + // Bytes 0-3 are 'VP8 ' + // Bytes 4-7 are the VP8 stream size + // Bytes 8-10 are the frame tag + // Bytes 11-13 are 0x9D 0x01 0x2A called the sync code + $syncCode = substr( $header, 11, 3 ); + if ( $syncCode != "\x9D\x01\x2A" ) { + wfDebugLog( 'WebP', __METHOD__ . ': Invalid sync code: ' . + bin2hex( $syncCode ) . "\n" ); + return []; + } + // Bytes 14-17 are image size + $imageSize = unpack( 'v2', substr( $header, 14, 4 ) ); + // Image sizes are 14 bit, 2 MSB are scaling parameters which are ignored here + return [ + 'compression' => 'lossy', + 'width' => $imageSize[1] & 0x3FFF, + 'height' => $imageSize[2] & 0x3FFF + ]; + } + + /** + * Decodes a lossless chunk header + * @param string $header First few bytes of the header, expected to be at least 13 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + public static function decodeLosslessChunkHeader( $header ) { + // Bytes 0-3 are 'VP8L' + // Bytes 4-7 are chunk stream size + // Byte 8 is 0x2F called the signature + if ( $header{8} != "\x2F" ) { + wfDebugLog( 'WebP', __METHOD__ . ': Invalid signature: ' . + bin2hex( $header{8} ) . "\n" ); + return []; + } + // Bytes 9-12 contain the image size + // Bits 0-13 are width-1; bits 15-27 are height-1 + $imageSize = unpack( 'C4', substr( $header, 9, 4 ) ); + return [ + 'compression' => 'lossless', + 'width' => ( $imageSize[1] | ( ( $imageSize[2] & 0x3F ) << 8 ) ) + 1, + 'height' => ( ( ( $imageSize[2] & 0xC0 ) >> 6 ) | + ( $imageSize[3] << 2 ) | ( ( $imageSize[4] & 0x03 ) << 10 ) ) + 1 + ]; + } + + /** + * Decodes an extended chunk header + * @param string $header First few bytes of the header, expected to be at least 18 bytes long + * @return bool|array See WebPHandler::decodeHeader + */ + public static function decodeExtendedChunkHeader( $header ) { + // Bytes 0-3 are 'VP8X' + // Byte 4-7 are chunk length + // Byte 8-11 are a flag bytes + $flags = unpack( 'c', substr( $header, 8, 1 ) ); + + // Byte 12-17 are image size (24 bits) + $width = unpack( 'V', substr( $header, 12, 3 ) . "\x00" ); + $height = unpack( 'V', substr( $header, 15, 3 ) . "\x00" ); + + return [ + 'compression' => 'unknown', + 'animated' => ( $flags[1] & self::VP8X_ANIM ) == self::VP8X_ANIM, + 'transparency' => ( $flags[1] & self::VP8X_ALPHA ) == self::VP8X_ALPHA, + 'width' => ( $width[1] & 0xFFFFFF ) + 1, + 'height' => ( $height[1] & 0xFFFFFF ) + 1 + ]; + } + + public function getImageSize( $file, $path, $metadata = false ) { + if ( $file === null ) { + $metadata = self::getMetadata( $file, $path ); + } + if ( $metadata === false && $file instanceof File ) { + $metadata = $file->getMetadata(); + } + + Wikimedia\suppressWarnings(); + $metadata = unserialize( $metadata ); + Wikimedia\restoreWarnings(); + + if ( $metadata == false ) { + return false; + } + return [ $metadata['width'], $metadata['height'] ]; + } + + /** + * @param File $file + * @return bool True, not all browsers support WebP + */ + public function mustRender( $file ) { + return true; + } + + /** + * @param File $file + * @return bool False if we are unable to render this image + */ + public function canRender( $file ) { + if ( self::isAnimatedImage( $file ) ) { + return false; + } + return true; + } + + /** + * @param File $image + * @return bool + */ + public function isAnimatedImage( $image ) { + $ser = $image->getMetadata(); + if ( $ser ) { + $metadata = unserialize( $ser ); + if ( isset( $metadata['animated'] ) && $metadata['animated'] === true ) { + return true; + } + } + + return false; + } + + public function canAnimateThumbnail( $file ) { + return false; + } + + /** + * Render files as PNG + * + * @param string $ext + * @param string $mime + * @param array|null $params + * @return array + */ + public function getThumbType( $ext, $mime, $params = null ) { + return [ 'png', 'image/png' ]; + } + + /** + * Must use "im" for XCF + * + * @param string $dstPath + * @param bool $checkDstPath + * @return string + */ + protected function getScalerType( $dstPath, $checkDstPath = true ) { + return 'im'; + } +} diff --git a/resources/src/mediawiki.special/mediawiki.special.upload.js b/resources/src/mediawiki.special/mediawiki.special.upload.js index 9120e2ae46..144659a7fb 100644 --- a/resources/src/mediawiki.special/mediawiki.special.upload.js +++ b/resources/src/mediawiki.special/mediawiki.special.upload.js @@ -413,7 +413,7 @@ if ( meta && meta.tiff && meta.tiff.Orientation ) { rotation = ( 360 - ( function () { - // See includes/media/Bitmap.php + // See BitmapHandler class in PHP switch ( meta.tiff.Orientation.value ) { case 8: return 90;