MediaWiki  master
TransformationalImageHandler.php
Go to the documentation of this file.
1 <?php
33 
51  public function normaliseParams( $image, &$params ) {
52  if ( !parent::normaliseParams( $image, $params ) ) {
53  return false;
54  }
55 
56  # Obtain the source, pre-rotation dimensions
57  $srcWidth = $image->getWidth( $params['page'] );
58  $srcHeight = $image->getHeight( $params['page'] );
59 
60  # Don't make an image bigger than the source
61  if ( $params['physicalWidth'] >= $srcWidth ) {
62  $params['physicalWidth'] = $srcWidth;
63  $params['physicalHeight'] = $srcHeight;
64 
65  # Skip scaling limit checks if no scaling is required
66  # due to requested size being bigger than source.
67  if ( !$image->mustRender() ) {
68  return true;
69  }
70  }
71 
72  return true;
73  }
74 
87  public function extractPreRotationDimensions( $params, $rotation ) {
88  if ( $rotation === 90 || $rotation === 270 ) {
89  // We'll resize before rotation, so swap the dimensions again
90  $width = $params['physicalHeight'];
91  $height = $params['physicalWidth'];
92  } else {
93  $width = $params['physicalWidth'];
94  $height = $params['physicalHeight'];
95  }
96 
97  return [ $width, $height ];
98  }
99 
114  public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
115  if ( !$this->normaliseParams( $image, $params ) ) {
116  return new TransformParameterError( $params );
117  }
118 
119  // Create a parameter array to pass to the scaler
120  $scalerParams = [
121  // The size to which the image will be resized
122  'physicalWidth' => $params['physicalWidth'],
123  'physicalHeight' => $params['physicalHeight'],
124  'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
125  // The size of the image on the page
126  'clientWidth' => $params['width'],
127  'clientHeight' => $params['height'],
128  // Comment as will be added to the Exif of the thumbnail
129  'comment' => isset( $params['descriptionUrl'] )
130  ? "File source: {$params['descriptionUrl']}"
131  : '',
132  // Properties of the original image
133  'srcWidth' => $image->getWidth(),
134  'srcHeight' => $image->getHeight(),
135  'mimeType' => $image->getMimeType(),
136  'dstPath' => $dstPath,
137  'dstUrl' => $dstUrl,
138  'interlace' => $params['interlace'] ?? false,
139  'isFilePageThumb' => $params['isFilePageThumb'] ?? false,
140  ];
141 
142  if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
143  $scalerParams['quality'] = 30;
144  }
145 
146  // For subclasses that might be paged.
147  if ( $image->isMultipage() && isset( $params['page'] ) ) {
148  $scalerParams['page'] = (int)$params['page'];
149  }
150 
151  # Determine scaler type
152  $scaler = $this->getScalerType( $dstPath );
153 
154  if ( is_array( $scaler ) ) {
155  $scalerName = get_class( $scaler[0] );
156  } else {
157  $scalerName = $scaler;
158  }
159 
160  wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
161  "thumbnail at $dstPath using scaler $scalerName" );
162 
163  if ( !$image->mustRender() &&
164  $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
165  && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
166  && !isset( $scalerParams['quality'] )
167  ) {
168  # normaliseParams (or the user) wants us to return the unscaled image
169  wfDebug( __METHOD__ . ": returning unscaled image" );
170 
171  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
172  }
173 
174  if ( $scaler === 'client' ) {
175  # Client-side image scaling, use the source URL
176  # Using the destination URL in a TRANSFORM_LATER request would be incorrect
177  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
178  }
179 
180  if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
181  $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
182  return new TransformTooBigImageAreaError( $params, $maxImageArea );
183  }
184 
185  if ( $flags & self::TRANSFORM_LATER ) {
186  wfDebug( __METHOD__ . ": Transforming later per flags." );
187  $newParams = [
188  'width' => $scalerParams['clientWidth'],
189  'height' => $scalerParams['clientHeight']
190  ];
191  if ( isset( $params['quality'] ) ) {
192  $newParams['quality'] = $params['quality'];
193  }
194  if ( isset( $params['page'] ) && $params['page'] ) {
195  $newParams['page'] = $params['page'];
196  }
197  return new ThumbnailImage( $image, $dstUrl, false, $newParams );
198  }
199 
200  # Try to make a target path for the thumbnail
201  if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
202  wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
203  "directory, falling back to client scaling" );
204 
205  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
206  }
207 
208  # Transform functions and binaries need a FS source file
209  $thumbnailSource = $this->getThumbnailSource( $image, $params );
210 
211  // If the source isn't the original, disable EXIF rotation because it's already been applied
212  if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
213  || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
214  $scalerParams['disableRotation'] = true;
215  }
216 
217  $scalerParams['srcPath'] = $thumbnailSource['path'];
218  $scalerParams['srcWidth'] = $thumbnailSource['width'];
219  $scalerParams['srcHeight'] = $thumbnailSource['height'];
220 
221  if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
222  wfDebugLog( 'thumbnail',
223  sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
224  wfHostname(), $image->getName() ) );
225 
226  return new MediaTransformError( 'thumbnail_error',
227  $scalerParams['clientWidth'], $scalerParams['clientHeight'],
228  wfMessage( 'filemissing' )
229  );
230  }
231 
232  // Try a hook. Called "Bitmap" for historical reasons.
234  $mto = null;
235  ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
236  ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
237  if ( $mto !== null ) {
238  wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
239  $scaler = 'hookaborted';
240  }
241 
242  // $scaler will return a MediaTransformError on failure, or false on success.
243  // If the scaler is successful, it will have created a thumbnail at the destination
244  // path.
245  if ( is_array( $scaler ) && is_callable( $scaler ) ) {
246  // Allow subclasses to specify their own rendering methods.
247  $err = call_user_func( $scaler, $image, $scalerParams );
248  } else {
249  switch ( $scaler ) {
250  case 'hookaborted':
251  # Handled by the hook above
252  $err = $mto->isError() ? $mto : false;
253  break;
254  case 'im':
255  $err = $this->transformImageMagick( $image, $scalerParams );
256  break;
257  case 'custom':
258  $err = $this->transformCustom( $image, $scalerParams );
259  break;
260  case 'imext':
261  $err = $this->transformImageMagickExt( $image, $scalerParams );
262  break;
263  case 'gd':
264  default:
265  $err = $this->transformGd( $image, $scalerParams );
266  break;
267  }
268  }
269 
270  // Remove the file if a zero-byte thumbnail was created, or if there was an error
271  // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
272  $removed = $this->removeBadFile( $dstPath, (bool)$err );
273  if ( $err ) {
274  # transform returned MediaTransforError
275  return $err;
276  }
277 
278  if ( $removed ) {
279  // Thumbnail was zero-byte and had to be removed
280  return new MediaTransformError( 'thumbnail_error',
281  $scalerParams['clientWidth'], $scalerParams['clientHeight'],
282  wfMessage( 'unknown-error' )
283  );
284  }
285 
286  if ( $mto ) {
287  // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
288  return $mto;
289  }
290 
291  $newParams = [
292  'width' => $scalerParams['clientWidth'],
293  'height' => $scalerParams['clientHeight']
294  ];
295  if ( isset( $params['quality'] ) ) {
296  $newParams['quality'] = $params['quality'];
297  }
298  if ( isset( $params['page'] ) && $params['page'] ) {
299  $newParams['page'] = $params['page'];
300  }
301  return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
302  }
303 
311  protected function getThumbnailSource( $file, $params ) {
312  return $file->getThumbnailSource( $params );
313  }
314 
336  abstract protected function getScalerType( $dstPath, $checkDstPath = true );
337 
349  protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
350  $params = [
351  'width' => $scalerParams['clientWidth'],
352  'height' => $scalerParams['clientHeight']
353  ];
354 
355  $url = $image->getUrl();
356  if ( isset( $scalerParams['isFilePageThumb'] ) && $scalerParams['isFilePageThumb'] ) {
357  // Use a versioned URL on file description pages
358  $url = $image->getFilePageThumbUrl( $url );
359  }
360 
361  return new ThumbnailImage( $image, $url, null, $params );
362  }
363 
375  protected function transformImageMagick( $image, $params ) {
376  return $this->getMediaTransformError( $params, "Unimplemented" );
377  }
378 
390  protected function transformImageMagickExt( $image, $params ) {
391  return $this->getMediaTransformError( $params, "Unimplemented" );
392  }
393 
405  protected function transformCustom( $image, $params ) {
406  return $this->getMediaTransformError( $params, "Unimplemented" );
407  }
408 
416  public function getMediaTransformError( $params, $errMsg ) {
417  return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
418  $params['clientHeight'], $errMsg );
419  }
420 
431  protected function transformGd( $image, $params ) {
432  return $this->getMediaTransformError( $params, "Unimplemented" );
433  }
434 
441  protected function escapeMagickProperty( $s ) {
442  // Double the backslashes
443  $s = str_replace( '\\', '\\\\', $s );
444  // Double the percents
445  $s = str_replace( '%', '%%', $s );
446  // Escape initial - or @
447  if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
448  $s = '\\' . $s;
449  }
450 
451  return $s;
452  }
453 
470  protected function escapeMagickInput( $path, $scene = false ) {
471  # Die on initial metacharacters (caller should prepend path)
472  $firstChar = substr( $path, 0, 1 );
473  if ( $firstChar === '~' || $firstChar === '@' ) {
474  throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
475  }
476 
477  # Escape glob chars
478  $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
479 
480  return $this->escapeMagickPath( $path, $scene );
481  }
482 
490  protected function escapeMagickOutput( $path, $scene = false ) {
491  $path = str_replace( '%', '%%', $path );
492 
493  return $this->escapeMagickPath( $path, $scene );
494  }
495 
504  protected function escapeMagickPath( $path, $scene = false ) {
505  # Die on format specifiers (other than drive letters). The regex is
506  # meant to match all the formats you get from "convert -list format"
507  if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
508  if ( wfIsWindows() && is_dir( $m[0] ) ) {
509  // OK, it's a drive letter
510  // ImageMagick has a similar exception, see IsMagickConflict()
511  } else {
512  throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
513  }
514  }
515 
516  # If there are square brackets, add a do-nothing scene specification
517  # to force a literal interpretation
518  if ( $scene === false ) {
519  if ( strpos( $path, '[' ) !== false ) {
520  $path .= '[0--1]';
521  }
522  } else {
523  $path .= "[$scene]";
524  }
525 
526  return $path;
527  }
528 
535  protected function getMagickVersion() {
536  $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
537  $method = __METHOD__;
538  return $cache->getWithSetCallback(
539  $cache->makeGlobalKey( 'imagemagick-version' ),
540  $cache::TTL_HOUR,
541  static function () use ( $method ) {
542  $imageMagickConvertCommand = MediaWikiServices::getInstance()
543  ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
544 
545  $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
546  wfDebug( $method . ": Running convert -version" );
547  $retval = '';
548  $return = wfShellExecWithStderr( $cmd, $retval );
549  $x = preg_match(
550  '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
551  );
552  if ( $x != 1 ) {
553  wfDebug( $method . ": ImageMagick version check failed" );
554  return false;
555  }
556 
557  return $matches[1];
558  }
559  );
560  }
561 
569  public function canRotate() {
570  return false;
571  }
572 
581  public function autoRotateEnabled() {
582  return false;
583  }
584 
597  public function rotate( $file, $params ) {
598  return new MediaTransformError( 'thumbnail_error', 0, 0,
599  static::class . ' rotation not implemented' );
600  }
601 
610  public function mustRender( $file ) {
611  return $this->canRotate() && $this->getRotation( $file ) != 0;
612  }
613 
625  public function isImageAreaOkForThumbnaling( $file, &$params ) {
626  $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
627 
628  # For historical reasons, hook starts with BitmapHandler
629  $checkImageAreaHookResult = null;
630  ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
631  $file, $params, $checkImageAreaHookResult );
632 
633  if ( $checkImageAreaHookResult !== null ) {
634  // was set by hook, so return that value
635  return (bool)$checkImageAreaHookResult;
636  }
637 
638  if ( $maxImageArea === false ) {
639  // Checking is disabled, fine to thumbnail
640  return true;
641  }
642 
643  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
644  $srcWidth = $file->getWidth( $params['page'] );
645  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
646  $srcHeight = $file->getHeight( $params['page'] );
647 
648  if ( $srcWidth * $srcHeight > $maxImageArea
649  && !( $file->getMimeType() === 'image/jpeg'
650  && $this->getScalerType( null, false ) === 'im' )
651  ) {
652  # Only ImageMagick can efficiently downsize jpg images without loading
653  # the entire file in memory
654  return false;
655  }
656  return true;
657  }
658 }
wfIsWindows()
Check if the operating system is Windows.
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfHostname()
Get host name of the current machine, for use in error reporting.
wfShellExecWithStderr( $cmd, &$retval=null, $environ=[], $limits=[])
Execute a shell command, returning both stdout and stderr.
wfDebugLog( $logGroup, $text, $dest='all', array $context=[])
Send a line to a supplementary debug log file, if configured, or main debug log if not.
wfMkdirParents( $dir, $mode=null, $caller=null)
Make directory, and make all parent directories if they don't exist.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
$matches
Media handler abstract base class for images.
getRotation( $file)
On supporting image formats, try to read out the low-level orientation of the file and return the ang...
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
Basic media transform error class.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:567
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Executes shell commands.
Definition: Shell.php:46
Media transform output for images.
Shortcut class for parameter validation errors.
Shortcut class for parameter file size errors.
Handler for images that need to be transformed.
getMagickVersion()
Retrieve the version of the installed ImageMagick You can use PHPs version_compare() to use this valu...
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick's input filenames.
autoRotateEnabled()
Should we automatically rotate an image based on exif.
isImageAreaOkForThumbnaling( $file, &$params)
Check if the file is smaller than the maximum image area for thumbnailing.
getThumbnailSource( $file, $params)
Get the source file for the transform.
getClientScalingThumbnailImage( $image, $scalerParams)
Get a ThumbnailImage that respresents an image that will be scaled client side.
getScalerType( $dstPath, $checkDstPath=true)
Returns what sort of scaler type should be used.
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
Create a thumbnail.
escapeMagickPath( $path, $scene=false)
Armour a string against ImageMagick's GetPathComponent().
escapeMagickProperty( $s)
Escape a string for ImageMagick's property input (e.g.
canRotate()
Returns whether the current scaler supports rotation.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
transformGd( $image, $params)
Transform an image using the built in GD library.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick's output filename.
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
transformCustom( $image, $params)
Transform an image using a custom command.
mustRender( $file)
Returns whether the file needs to be rendered.
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error 'thumbnail_error'.
rotate( $file, $params)
Rotate a thumbnail.
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42