1<?php 2/** 3 * Base class for handlers which require transforming images in a 4 * similar way as BitmapHandler does. 5 * 6 * This was split from BitmapHandler on the basis that some extensions 7 * might want to work in a similar way to BitmapHandler, but for 8 * different formats. 9 * 10 * This program is free software; you can redistribute it and/or modify 11 * it under the terms of the GNU General Public License as published by 12 * the Free Software Foundation; either version 2 of the License, or 13 * (at your option) any later version. 14 * 15 * This program is distributed in the hope that it will be useful, 16 * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 * GNU General Public License for more details. 19 * 20 * You should have received a copy of the GNU General Public License along 21 * with this program; if not, write to the Free Software Foundation, Inc., 22 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 23 * http://www.gnu.org/copyleft/gpl.html 24 * 25 * @file 26 * @ingroup Media 27 */ 28use MediaWiki\MediaWikiServices; 29use MediaWiki\Shell\Shell; 30 31/** 32 * Handler for images that need to be transformed 33 * 34 * @stable to extend 35 * 36 * @since 1.24 37 * @ingroup Media 38 */ 39abstract class TransformationalImageHandler extends ImageHandler { 40 /** 41 * @stable to override 42 * @param File $image 43 * @param array &$params Transform parameters. Entries with the keys 'width' 44 * and 'height' are the respective screen width and height, while the keys 45 * 'physicalWidth' and 'physicalHeight' indicate the thumbnail dimensions. 46 * @return bool 47 */ 48 public function normaliseParams( $image, &$params ) { 49 if ( !parent::normaliseParams( $image, $params ) ) { 50 return false; 51 } 52 53 # Obtain the source, pre-rotation dimensions 54 $srcWidth = $image->getWidth( $params['page'] ); 55 $srcHeight = $image->getHeight( $params['page'] ); 56 57 # Don't make an image bigger than the source 58 if ( $params['physicalWidth'] >= $srcWidth ) { 59 $params['physicalWidth'] = $srcWidth; 60 $params['physicalHeight'] = $srcHeight; 61 62 # Skip scaling limit checks if no scaling is required 63 # due to requested size being bigger than source. 64 if ( !$image->mustRender() ) { 65 return true; 66 } 67 } 68 69 return true; 70 } 71 72 /** 73 * Extracts the width/height if the image will be scaled before rotating 74 * 75 * This will match the physical size/aspect ratio of the original image 76 * prior to application of the rotation -- so for a portrait image that's 77 * stored as raw landscape with 90-degress rotation, the resulting size 78 * will be wider than it is tall. 79 * 80 * @param array $params Parameters as returned by normaliseParams 81 * @param int $rotation The rotation angle that will be applied 82 * @return array ($width, $height) array 83 */ 84 public function extractPreRotationDimensions( $params, $rotation ) { 85 if ( $rotation == 90 || $rotation == 270 ) { 86 # We'll resize before rotation, so swap the dimensions again 87 $width = $params['physicalHeight']; 88 $height = $params['physicalWidth']; 89 } else { 90 $width = $params['physicalWidth']; 91 $height = $params['physicalHeight']; 92 } 93 94 return [ $width, $height ]; 95 } 96 97 /** 98 * Create a thumbnail. 99 * 100 * This sets up various parameters, and then calls a helper method 101 * based on $this->getScalerType in order to scale the image. 102 * @stable to override 103 * 104 * @param File $image 105 * @param string $dstPath 106 * @param string $dstUrl 107 * @param array $params 108 * @param int $flags 109 * @return MediaTransformError|ThumbnailImage|TransformParameterError 110 */ 111 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) { 112 if ( !$this->normaliseParams( $image, $params ) ) { 113 return new TransformParameterError( $params ); 114 } 115 116 # Create a parameter array to pass to the scaler 117 $scalerParams = [ 118 # The size to which the image will be resized 119 'physicalWidth' => $params['physicalWidth'], 120 'physicalHeight' => $params['physicalHeight'], 121 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}", 122 # The size of the image on the page 123 'clientWidth' => $params['width'], 124 'clientHeight' => $params['height'], 125 # Comment as will be added to the Exif of the thumbnail 126 'comment' => isset( $params['descriptionUrl'] ) 127 ? "File source: {$params['descriptionUrl']}" 128 : '', 129 # Properties of the original image 130 'srcWidth' => $image->getWidth(), 131 'srcHeight' => $image->getHeight(), 132 'mimeType' => $image->getMimeType(), 133 'dstPath' => $dstPath, 134 'dstUrl' => $dstUrl, 135 'interlace' => $params['interlace'] ?? false, 136 ]; 137 138 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) { 139 $scalerParams['quality'] = 30; 140 } 141 142 // For subclasses that might be paged. 143 if ( $image->isMultipage() && isset( $params['page'] ) ) { 144 $scalerParams['page'] = intval( $params['page'] ); 145 } 146 147 # Determine scaler type 148 $scaler = $this->getScalerType( $dstPath ); 149 150 if ( is_array( $scaler ) ) { 151 $scalerName = get_class( $scaler[0] ); 152 } else { 153 $scalerName = $scaler; 154 } 155 156 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " . 157 "thumbnail at $dstPath using scaler $scalerName" ); 158 159 if ( !$image->mustRender() && 160 $scalerParams['physicalWidth'] == $scalerParams['srcWidth'] 161 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight'] 162 && !isset( $scalerParams['quality'] ) 163 ) { 164 # normaliseParams (or the user) wants us to return the unscaled image 165 wfDebug( __METHOD__ . ": returning unscaled image" ); 166 167 return $this->getClientScalingThumbnailImage( $image, $scalerParams ); 168 } 169 170 if ( $scaler == 'client' ) { 171 # Client-side image scaling, use the source URL 172 # Using the destination URL in a TRANSFORM_LATER request would be incorrect 173 return $this->getClientScalingThumbnailImage( $image, $scalerParams ); 174 } 175 176 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) { 177 global $wgMaxImageArea; 178 return new TransformTooBigImageAreaError( $params, $wgMaxImageArea ); 179 } 180 181 if ( $flags & self::TRANSFORM_LATER ) { 182 wfDebug( __METHOD__ . ": Transforming later per flags." ); 183 $newParams = [ 184 'width' => $scalerParams['clientWidth'], 185 'height' => $scalerParams['clientHeight'] 186 ]; 187 if ( isset( $params['quality'] ) ) { 188 $newParams['quality'] = $params['quality']; 189 } 190 if ( isset( $params['page'] ) && $params['page'] ) { 191 $newParams['page'] = $params['page']; 192 } 193 return new ThumbnailImage( $image, $dstUrl, false, $newParams ); 194 } 195 196 # Try to make a target path for the thumbnail 197 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) { 198 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " . 199 "directory, falling back to client scaling" ); 200 201 return $this->getClientScalingThumbnailImage( $image, $scalerParams ); 202 } 203 204 # Transform functions and binaries need a FS source file 205 $thumbnailSource = $this->getThumbnailSource( $image, $params ); 206 207 // If the source isn't the original, disable EXIF rotation because it's already been applied 208 if ( $scalerParams['srcWidth'] != $thumbnailSource['width'] 209 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) { 210 $scalerParams['disableRotation'] = true; 211 } 212 213 $scalerParams['srcPath'] = $thumbnailSource['path']; 214 $scalerParams['srcWidth'] = $thumbnailSource['width']; 215 $scalerParams['srcHeight'] = $thumbnailSource['height']; 216 217 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy 218 wfDebugLog( 'thumbnail', 219 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"', 220 wfHostname(), $image->getName() ) ); 221 222 return new MediaTransformError( 'thumbnail_error', 223 $scalerParams['clientWidth'], $scalerParams['clientHeight'], 224 wfMessage( 'filemissing' ) 225 ); 226 } 227 228 # Try a hook. Called "Bitmap" for historical reasons. 229 /** @var MediaTransformOutput $mto */ 230 $mto = null; 231 Hooks::runner()->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto ); 232 if ( $mto !== null ) { 233 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" ); 234 $scaler = 'hookaborted'; 235 } 236 237 // $scaler will return a MediaTransformError on failure, or false on success. 238 // If the scaler is successful, it will have created a thumbnail at the destination 239 // path. 240 if ( is_array( $scaler ) && is_callable( $scaler ) ) { 241 // Allow subclasses to specify their own rendering methods. 242 $err = call_user_func( $scaler, $image, $scalerParams ); 243 } else { 244 switch ( $scaler ) { 245 case 'hookaborted': 246 # Handled by the hook above 247 $err = $mto->isError() ? $mto : false; 248 break; 249 case 'im': 250 $err = $this->transformImageMagick( $image, $scalerParams ); 251 break; 252 case 'custom': 253 $err = $this->transformCustom( $image, $scalerParams ); 254 break; 255 case 'imext': 256 $err = $this->transformImageMagickExt( $image, $scalerParams ); 257 break; 258 case 'gd': 259 default: 260 $err = $this->transformGd( $image, $scalerParams ); 261 break; 262 } 263 } 264 265 # Remove the file if a zero-byte thumbnail was created, or if there was an error 266 $removed = $this->removeBadFile( $dstPath, (bool)$err ); 267 if ( $err ) { 268 # transform returned MediaTransforError 269 return $err; 270 } elseif ( $removed ) { 271 # Thumbnail was zero-byte and had to be removed 272 return new MediaTransformError( 'thumbnail_error', 273 $scalerParams['clientWidth'], $scalerParams['clientHeight'], 274 wfMessage( 'unknown-error' ) 275 ); 276 } elseif ( $mto ) { 277 return $mto; 278 } else { 279 $newParams = [ 280 'width' => $scalerParams['clientWidth'], 281 'height' => $scalerParams['clientHeight'] 282 ]; 283 if ( isset( $params['quality'] ) ) { 284 $newParams['quality'] = $params['quality']; 285 } 286 if ( isset( $params['page'] ) && $params['page'] ) { 287 $newParams['page'] = $params['page']; 288 } 289 return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams ); 290 } 291 } 292 293 /** 294 * Get the source file for the transform 295 * 296 * @param File $file 297 * @param array $params 298 * @return array Array with keys width, height and path. 299 */ 300 protected function getThumbnailSource( $file, $params ) { 301 return $file->getThumbnailSource( $params ); 302 } 303 304 /** 305 * Returns what sort of scaler type should be used. 306 * 307 * Values can be one of client, im, custom, gd, imext, or an array 308 * of object, method-name to call that specific method. 309 * 310 * If specifying a custom scaler command with [ Obj, method ], 311 * the method in question should take 2 parameters, a File object, 312 * and a $scalerParams array with various options (See doTransform 313 * for what is in $scalerParams). On error it should return a 314 * MediaTransformError object. On success it should return false, 315 * and simply make sure the thumbnail file is located at 316 * $scalerParams['dstPath']. 317 * 318 * If there is a problem with the output path, it returns "client" 319 * to do client side scaling. 320 * 321 * @param string $dstPath 322 * @param bool $checkDstPath Check that $dstPath is valid 323 * @return string|Callable One of client, im, custom, gd, imext, or a Callable array. 324 */ 325 abstract protected function getScalerType( $dstPath, $checkDstPath = true ); 326 327 /** 328 * Get a ThumbnailImage that respresents an image that will be scaled 329 * client side 330 * 331 * @stable to override 332 * @param File $image File associated with this thumbnail 333 * @param array $scalerParams Array with scaler params 334 * @return ThumbnailImage 335 * 336 * @todo FIXME: No rotation support 337 */ 338 protected function getClientScalingThumbnailImage( $image, $scalerParams ) { 339 $params = [ 340 'width' => $scalerParams['clientWidth'], 341 'height' => $scalerParams['clientHeight'] 342 ]; 343 344 return new ThumbnailImage( $image, $image->getUrl(), null, $params ); 345 } 346 347 /** 348 * Transform an image using ImageMagick 349 * 350 * This is a stub method. The real method is in BitmapHander. 351 * 352 * @stable to override 353 * @param File $image File associated with this thumbnail 354 * @param array $params Array with scaler params 355 * 356 * @return MediaTransformError Error object if error occurred, false (=no error) otherwise 357 */ 358 protected function transformImageMagick( $image, $params ) { 359 return $this->getMediaTransformError( $params, "Unimplemented" ); 360 } 361 362 /** 363 * Transform an image using the Imagick PHP extension 364 * 365 * This is a stub method. The real method is in BitmapHander. 366 * 367 * @stable to override 368 * @param File $image File associated with this thumbnail 369 * @param array $params Array with scaler params 370 * 371 * @return MediaTransformError Error object if error occurred, false (=no error) otherwise 372 */ 373 protected function transformImageMagickExt( $image, $params ) { 374 return $this->getMediaTransformError( $params, "Unimplemented" ); 375 } 376 377 /** 378 * Transform an image using a custom command 379 * 380 * This is a stub method. The real method is in BitmapHander. 381 * 382 * @stable to override 383 * @param File $image File associated with this thumbnail 384 * @param array $params Array with scaler params 385 * 386 * @return MediaTransformError Error object if error occurred, false (=no error) otherwise 387 */ 388 protected function transformCustom( $image, $params ) { 389 return $this->getMediaTransformError( $params, "Unimplemented" ); 390 } 391 392 /** 393 * Get a MediaTransformError with error 'thumbnail_error' 394 * 395 * @param array $params Parameter array as passed to the transform* functions 396 * @param string $errMsg Error message 397 * @return MediaTransformError 398 */ 399 public function getMediaTransformError( $params, $errMsg ) { 400 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'], 401 $params['clientHeight'], $errMsg ); 402 } 403 404 /** 405 * Transform an image using the built in GD library 406 * 407 * This is a stub method. The real method is in BitmapHander. 408 * 409 * @param File $image File associated with this thumbnail 410 * @param array $params Array with scaler params 411 * 412 * @return MediaTransformError Error object if error occurred, false (=no error) otherwise 413 */ 414 protected function transformGd( $image, $params ) { 415 return $this->getMediaTransformError( $params, "Unimplemented" ); 416 } 417 418 /** 419 * Escape a string for ImageMagick's property input (e.g. -set -comment) 420 * See InterpretImageProperties() in magick/property.c 421 * @param string $s 422 * @return string 423 */ 424 protected function escapeMagickProperty( $s ) { 425 // Double the backslashes 426 $s = str_replace( '\\', '\\\\', $s ); 427 // Double the percents 428 $s = str_replace( '%', '%%', $s ); 429 // Escape initial - or @ 430 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) { 431 $s = '\\' . $s; 432 } 433 434 return $s; 435 } 436 437 /** 438 * Escape a string for ImageMagick's input filenames. See ExpandFilenames() 439 * and GetPathComponent() in magick/utility.c. 440 * 441 * This won't work with an initial ~ or @, so input files should be prefixed 442 * with the directory name. 443 * 444 * Glob character unescaping is broken in ImageMagick before 6.6.1-5, but 445 * it's broken in a way that doesn't involve trying to convert every file 446 * in a directory, so we're better off escaping and waiting for the bugfix 447 * to filter down to users. 448 * 449 * @param string $path The file path 450 * @param bool|string $scene The scene specification, or false if there is none 451 * @throws MWException 452 * @return string 453 */ 454 protected function escapeMagickInput( $path, $scene = false ) { 455 # Die on initial metacharacters (caller should prepend path) 456 $firstChar = substr( $path, 0, 1 ); 457 if ( $firstChar === '~' || $firstChar === '@' ) { 458 throw new MWException( __METHOD__ . ': cannot escape this path name' ); 459 } 460 461 # Escape glob chars 462 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path ); 463 464 return $this->escapeMagickPath( $path, $scene ); 465 } 466 467 /** 468 * Escape a string for ImageMagick's output filename. See 469 * InterpretImageFilename() in magick/image.c. 470 * @param string $path The file path 471 * @param bool|string $scene The scene specification, or false if there is none 472 * @return string 473 */ 474 protected function escapeMagickOutput( $path, $scene = false ) { 475 $path = str_replace( '%', '%%', $path ); 476 477 return $this->escapeMagickPath( $path, $scene ); 478 } 479 480 /** 481 * Armour a string against ImageMagick's GetPathComponent(). This is a 482 * helper function for escapeMagickInput() and escapeMagickOutput(). 483 * 484 * @param string $path The file path 485 * @param bool|string $scene The scene specification, or false if there is none 486 * @throws MWException 487 * @return string 488 */ 489 protected function escapeMagickPath( $path, $scene = false ) { 490 # Die on format specifiers (other than drive letters). The regex is 491 # meant to match all the formats you get from "convert -list format" 492 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) { 493 if ( wfIsWindows() && is_dir( $m[0] ) ) { 494 // OK, it's a drive letter 495 // ImageMagick has a similar exception, see IsMagickConflict() 496 } else { 497 throw new MWException( __METHOD__ . ': unexpected colon character in path name' ); 498 } 499 } 500 501 # If there are square brackets, add a do-nothing scene specification 502 # to force a literal interpretation 503 if ( $scene === false ) { 504 if ( strpos( $path, '[' ) !== false ) { 505 $path .= '[0--1]'; 506 } 507 } else { 508 $path .= "[$scene]"; 509 } 510 511 return $path; 512 } 513 514 /** 515 * Retrieve the version of the installed ImageMagick 516 * You can use PHPs version_compare() to use this value 517 * Value is cached for one hour. 518 * @return string|bool Representing the IM version; false on error 519 */ 520 protected function getMagickVersion() { 521 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache(); 522 $method = __METHOD__; 523 return $cache->getWithSetCallback( 524 $cache->makeGlobalKey( 'imagemagick-version' ), 525 $cache::TTL_HOUR, 526 function () use ( $method ) { 527 global $wgImageMagickConvertCommand; 528 529 $cmd = Shell::escape( $wgImageMagickConvertCommand ) . ' -version'; 530 wfDebug( $method . ": Running convert -version" ); 531 $retval = ''; 532 $return = wfShellExecWithStderr( $cmd, $retval ); 533 $x = preg_match( 534 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches 535 ); 536 if ( $x != 1 ) { 537 wfDebug( $method . ": ImageMagick version check failed" ); 538 return false; 539 } 540 541 return $matches[1]; 542 } 543 ); 544 } 545 546 /** 547 * Returns whether the current scaler supports rotation. 548 * 549 * @since 1.24 No longer static 550 * @stable to override 551 * @return bool 552 */ 553 public function canRotate() { 554 return false; 555 } 556 557 /** 558 * Should we automatically rotate an image based on exif 559 * 560 * @since 1.24 No longer static 561 * @stable to override 562 * @see $wgEnableAutoRotation 563 * @return bool Whether auto rotation is enabled 564 */ 565 public function autoRotateEnabled() { 566 return false; 567 } 568 569 /** 570 * Rotate a thumbnail. 571 * 572 * This is a stub. See BitmapHandler::rotate. 573 * 574 * @stable to override 575 * @param File $file 576 * @param array $params Rotate parameters. 577 * 'rotation' clockwise rotation in degrees, allowed are multiples of 90 578 * @since 1.24 Is non-static. From 1.21 it was static 579 * @return bool|MediaTransformError 580 */ 581 public function rotate( $file, $params ) { 582 return new MediaTransformError( 'thumbnail_error', 0, 0, 583 static::class . ' rotation not implemented' ); 584 } 585 586 /** 587 * Returns whether the file needs to be rendered. Returns true if the 588 * file requires rotation and we are able to rotate it. 589 * 590 * @stable to override 591 * @param File $file 592 * @return bool 593 */ 594 public function mustRender( $file ) { 595 return $this->canRotate() && $this->getRotation( $file ) != 0; 596 } 597 598 /** 599 * Check if the file is smaller than the maximum image area for thumbnailing. 600 * 601 * Runs the 'BitmapHandlerCheckImageArea' hook. 602 * 603 * @stable to override 604 * @param File $file 605 * @param array &$params 606 * @return bool 607 * @since 1.25 608 */ 609 public function isImageAreaOkForThumbnaling( $file, &$params ) { 610 global $wgMaxImageArea; 611 612 # For historical reasons, hook starts with BitmapHandler 613 $checkImageAreaHookResult = null; 614 Hooks::runner()->onBitmapHandlerCheckImageArea( 615 $file, $params, $checkImageAreaHookResult ); 616 617 if ( $checkImageAreaHookResult !== null ) { 618 // was set by hook, so return that value 619 return (bool)$checkImageAreaHookResult; 620 } 621 622 $srcWidth = $file->getWidth( $params['page'] ); 623 $srcHeight = $file->getHeight( $params['page'] ); 624 625 if ( $srcWidth * $srcHeight > $wgMaxImageArea 626 && !( $file->getMimeType() == 'image/jpeg' 627 && $this->getScalerType( false, false ) == 'im' ) 628 ) { 629 # Only ImageMagick can efficiently downsize jpg images without loading 630 # the entire file in memory 631 return false; 632 } 633 return true; 634 } 635} 636