MediaWiki  master
TransformationalImageHandler.php
Go to the documentation of this file.
1 <?php
32 
50  public function normaliseParams( $image, &$params ) {
51  if ( !parent::normaliseParams( $image, $params ) ) {
52  return false;
53  }
54 
55  # Obtain the source, pre-rotation dimensions
56  $srcWidth = $image->getWidth( $params['page'] );
57  $srcHeight = $image->getHeight( $params['page'] );
58 
59  # Don't make an image bigger than the source
60  if ( $params['physicalWidth'] >= $srcWidth ) {
61  $params['physicalWidth'] = $srcWidth;
62  $params['physicalHeight'] = $srcHeight;
63 
64  # Skip scaling limit checks if no scaling is required
65  # due to requested size being bigger than source.
66  if ( !$image->mustRender() ) {
67  return true;
68  }
69  }
70 
71  return true;
72  }
73 
86  public function extractPreRotationDimensions( $params, $rotation ) {
87  if ( $rotation == 90 || $rotation == 270 ) {
88  # We'll resize before rotation, so swap the dimensions again
89  $width = $params['physicalHeight'];
90  $height = $params['physicalWidth'];
91  } else {
92  $width = $params['physicalWidth'];
93  $height = $params['physicalHeight'];
94  }
95 
96  return [ $width, $height ];
97  }
98 
113  public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
114  if ( !$this->normaliseParams( $image, $params ) ) {
115  return new TransformParameterError( $params );
116  }
117 
118  # Create a parameter array to pass to the scaler
119  $scalerParams = [
120  # The size to which the image will be resized
121  'physicalWidth' => $params['physicalWidth'],
122  'physicalHeight' => $params['physicalHeight'],
123  'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
124  # The size of the image on the page
125  'clientWidth' => $params['width'],
126  'clientHeight' => $params['height'],
127  # Comment as will be added to the Exif of the thumbnail
128  'comment' => isset( $params['descriptionUrl'] )
129  ? "File source: {$params['descriptionUrl']}"
130  : '',
131  # Properties of the original image
132  'srcWidth' => $image->getWidth(),
133  'srcHeight' => $image->getHeight(),
134  'mimeType' => $image->getMimeType(),
135  'dstPath' => $dstPath,
136  'dstUrl' => $dstUrl,
137  'interlace' => $params['interlace'] ?? false,
138  'isFilePageThumb' => $params['isFilePageThumb'] ?? false,
139  ];
140 
141  if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
142  $scalerParams['quality'] = 30;
143  }
144 
145  // For subclasses that might be paged.
146  if ( $image->isMultipage() && isset( $params['page'] ) ) {
147  $scalerParams['page'] = intval( $params['page'] );
148  }
149 
150  # Determine scaler type
151  $scaler = $this->getScalerType( $dstPath );
152 
153  if ( is_array( $scaler ) ) {
154  $scalerName = get_class( $scaler[0] );
155  } else {
156  $scalerName = $scaler;
157  }
158 
159  wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
160  "thumbnail at $dstPath using scaler $scalerName" );
161 
162  if ( !$image->mustRender() &&
163  $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
164  && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
165  && !isset( $scalerParams['quality'] )
166  ) {
167  # normaliseParams (or the user) wants us to return the unscaled image
168  wfDebug( __METHOD__ . ": returning unscaled image" );
169 
170  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
171  }
172 
173  if ( $scaler == 'client' ) {
174  # Client-side image scaling, use the source URL
175  # Using the destination URL in a TRANSFORM_LATER request would be incorrect
176  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
177  }
178 
179  if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
180  $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
181  return new TransformTooBigImageAreaError( $params, $maxImageArea );
182  }
183 
184  if ( $flags & self::TRANSFORM_LATER ) {
185  wfDebug( __METHOD__ . ": Transforming later per flags." );
186  $newParams = [
187  'width' => $scalerParams['clientWidth'],
188  'height' => $scalerParams['clientHeight']
189  ];
190  if ( isset( $params['quality'] ) ) {
191  $newParams['quality'] = $params['quality'];
192  }
193  if ( isset( $params['page'] ) && $params['page'] ) {
194  $newParams['page'] = $params['page'];
195  }
196  return new ThumbnailImage( $image, $dstUrl, false, $newParams );
197  }
198 
199  # Try to make a target path for the thumbnail
200  if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
201  wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
202  "directory, falling back to client scaling" );
203 
204  return $this->getClientScalingThumbnailImage( $image, $scalerParams );
205  }
206 
207  # Transform functions and binaries need a FS source file
208  $thumbnailSource = $this->getThumbnailSource( $image, $params );
209 
210  // If the source isn't the original, disable EXIF rotation because it's already been applied
211  if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
212  || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
213  $scalerParams['disableRotation'] = true;
214  }
215 
216  $scalerParams['srcPath'] = $thumbnailSource['path'];
217  $scalerParams['srcWidth'] = $thumbnailSource['width'];
218  $scalerParams['srcHeight'] = $thumbnailSource['height'];
219 
220  if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
221  wfDebugLog( 'thumbnail',
222  sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
223  wfHostname(), $image->getName() ) );
224 
225  return new MediaTransformError( 'thumbnail_error',
226  $scalerParams['clientWidth'], $scalerParams['clientHeight'],
227  wfMessage( 'filemissing' )
228  );
229  }
230 
231  # Try a hook. Called "Bitmap" for historical reasons.
233  $mto = null;
234  Hooks::runner()->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
235  if ( $mto !== null ) {
236  wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
237  $scaler = 'hookaborted';
238  }
239 
240  // $scaler will return a MediaTransformError on failure, or false on success.
241  // If the scaler is successful, it will have created a thumbnail at the destination
242  // path.
243  if ( is_array( $scaler ) && is_callable( $scaler ) ) {
244  // Allow subclasses to specify their own rendering methods.
245  $err = call_user_func( $scaler, $image, $scalerParams );
246  } else {
247  switch ( $scaler ) {
248  case 'hookaborted':
249  # Handled by the hook above
250  $err = $mto->isError() ? $mto : false;
251  break;
252  case 'im':
253  $err = $this->transformImageMagick( $image, $scalerParams );
254  break;
255  case 'custom':
256  $err = $this->transformCustom( $image, $scalerParams );
257  break;
258  case 'imext':
259  $err = $this->transformImageMagickExt( $image, $scalerParams );
260  break;
261  case 'gd':
262  default:
263  $err = $this->transformGd( $image, $scalerParams );
264  break;
265  }
266  }
267 
268  # Remove the file if a zero-byte thumbnail was created, or if there was an error
269  // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
270  $removed = $this->removeBadFile( $dstPath, (bool)$err );
271  if ( $err ) {
272  # transform returned MediaTransforError
273  return $err;
274  } elseif ( $removed ) {
275  # Thumbnail was zero-byte and had to be removed
276  return new MediaTransformError( 'thumbnail_error',
277  $scalerParams['clientWidth'], $scalerParams['clientHeight'],
278  wfMessage( 'unknown-error' )
279  );
280  } elseif ( $mto ) {
281  // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
282  return $mto;
283  } else {
284  $newParams = [
285  'width' => $scalerParams['clientWidth'],
286  'height' => $scalerParams['clientHeight']
287  ];
288  if ( isset( $params['quality'] ) ) {
289  $newParams['quality'] = $params['quality'];
290  }
291  if ( isset( $params['page'] ) && $params['page'] ) {
292  $newParams['page'] = $params['page'];
293  }
294  return new ThumbnailImage( $image, $dstUrl, $dstPath, $newParams );
295  }
296  }
297 
305  protected function getThumbnailSource( $file, $params ) {
306  return $file->getThumbnailSource( $params );
307  }
308 
330  abstract protected function getScalerType( $dstPath, $checkDstPath = true );
331 
343  protected function getClientScalingThumbnailImage( $image, $scalerParams ) {
344  $params = [
345  'width' => $scalerParams['clientWidth'],
346  'height' => $scalerParams['clientHeight']
347  ];
348 
349  $url = $image->getUrl();
350  if ( isset( $scalerParams['isFilePageThumb'] ) && $scalerParams['isFilePageThumb'] ) {
351  // Use a versioned URL on file description pages
352  $url = $image->getFilePageThumbUrl( $url );
353  }
354 
355  return new ThumbnailImage( $image, $url, null, $params );
356  }
357 
369  protected function transformImageMagick( $image, $params ) {
370  return $this->getMediaTransformError( $params, "Unimplemented" );
371  }
372 
384  protected function transformImageMagickExt( $image, $params ) {
385  return $this->getMediaTransformError( $params, "Unimplemented" );
386  }
387 
399  protected function transformCustom( $image, $params ) {
400  return $this->getMediaTransformError( $params, "Unimplemented" );
401  }
402 
410  public function getMediaTransformError( $params, $errMsg ) {
411  return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
412  $params['clientHeight'], $errMsg );
413  }
414 
425  protected function transformGd( $image, $params ) {
426  return $this->getMediaTransformError( $params, "Unimplemented" );
427  }
428 
435  protected function escapeMagickProperty( $s ) {
436  // Double the backslashes
437  $s = str_replace( '\\', '\\\\', $s );
438  // Double the percents
439  $s = str_replace( '%', '%%', $s );
440  // Escape initial - or @
441  if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
442  $s = '\\' . $s;
443  }
444 
445  return $s;
446  }
447 
465  protected function escapeMagickInput( $path, $scene = false ) {
466  # Die on initial metacharacters (caller should prepend path)
467  $firstChar = substr( $path, 0, 1 );
468  if ( $firstChar === '~' || $firstChar === '@' ) {
469  throw new MWException( __METHOD__ . ': cannot escape this path name' );
470  }
471 
472  # Escape glob chars
473  $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
474 
475  return $this->escapeMagickPath( $path, $scene );
476  }
477 
485  protected function escapeMagickOutput( $path, $scene = false ) {
486  $path = str_replace( '%', '%%', $path );
487 
488  return $this->escapeMagickPath( $path, $scene );
489  }
490 
500  protected function escapeMagickPath( $path, $scene = false ) {
501  # Die on format specifiers (other than drive letters). The regex is
502  # meant to match all the formats you get from "convert -list format"
503  if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
504  if ( wfIsWindows() && is_dir( $m[0] ) ) {
505  // OK, it's a drive letter
506  // ImageMagick has a similar exception, see IsMagickConflict()
507  } else {
508  throw new MWException( __METHOD__ . ': unexpected colon character in path name' );
509  }
510  }
511 
512  # If there are square brackets, add a do-nothing scene specification
513  # to force a literal interpretation
514  if ( $scene === false ) {
515  if ( strpos( $path, '[' ) !== false ) {
516  $path .= '[0--1]';
517  }
518  } else {
519  $path .= "[$scene]";
520  }
521 
522  return $path;
523  }
524 
531  protected function getMagickVersion() {
532  $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
533  $method = __METHOD__;
534  return $cache->getWithSetCallback(
535  $cache->makeGlobalKey( 'imagemagick-version' ),
536  $cache::TTL_HOUR,
537  static function () use ( $method ) {
538  $imageMagickConvertCommand = MediaWikiServices::getInstance()
539  ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
540 
541  $cmd = Shell::escape( $imageMagickConvertCommand ) . ' -version';
542  wfDebug( $method . ": Running convert -version" );
543  $retval = '';
544  $return = wfShellExecWithStderr( $cmd, $retval );
545  $x = preg_match(
546  '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
547  );
548  if ( $x != 1 ) {
549  wfDebug( $method . ": ImageMagick version check failed" );
550  return false;
551  }
552 
553  return $matches[1];
554  }
555  );
556  }
557 
565  public function canRotate() {
566  return false;
567  }
568 
577  public function autoRotateEnabled() {
578  return false;
579  }
580 
593  public function rotate( $file, $params ) {
594  return new MediaTransformError( 'thumbnail_error', 0, 0,
595  static::class . ' rotation not implemented' );
596  }
597 
606  public function mustRender( $file ) {
607  return $this->canRotate() && $this->getRotation( $file ) != 0;
608  }
609 
621  public function isImageAreaOkForThumbnaling( $file, &$params ) {
622  $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
623 
624  # For historical reasons, hook starts with BitmapHandler
625  $checkImageAreaHookResult = null;
626  Hooks::runner()->onBitmapHandlerCheckImageArea(
627  $file, $params, $checkImageAreaHookResult );
628 
629  if ( $checkImageAreaHookResult !== null ) {
630  // was set by hook, so return that value
631  return (bool)$checkImageAreaHookResult;
632  }
633 
634  if ( $maxImageArea === false ) {
635  // Checking is disabled, fine to thumbnail
636  return true;
637  }
638 
639  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
640  $srcWidth = $file->getWidth( $params['page'] );
641  // @phan-suppress-next-line PhanTypePossiblyInvalidDimOffset Checked by normaliseParams
642  $srcHeight = $file->getHeight( $params['page'] );
643 
644  if ( $srcWidth * $srcHeight > $maxImageArea
645  && !( $file->getMimeType() == 'image/jpeg'
646  && $this->getScalerType( null, false ) == 'im' )
647  ) {
648  # Only ImageMagick can efficiently downsize jpg images without loading
649  # the entire file in memory
650  return false;
651  }
652  return true;
653  }
654 }
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.
wfIsWindows()
Check if the operating system is Windows.
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
static runner()
Get a HookRunner instance for calling hooks using the new interfaces.
Definition: Hooks.php:173
Media handler abstract base class for images.
MediaWiki exception.
Definition: MWException.php:29
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.
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.
$cache
Definition: mcc.php:33
foreach( $mmfl['setupFiles'] as $fileName) if( $queue) if(empty( $mmfl['quiet'])) $s
if(PHP_SAPI !='cli-server') if(!isset( $_SERVER['SCRIPT_FILENAME'])) $file
Item class for a filearchive table row.
Definition: router.php:42