MediaWiki master
TransformationalImageHandler.php
Go to the documentation of this file.
1<?php
15namespace MediaWiki\Media;
16
17use InvalidArgumentException;
23
41 public function normaliseParams( $image, &$params ) {
42 if ( !parent::normaliseParams( $image, $params ) ) {
43 return false;
44 }
45
46 # Obtain the source, pre-rotation dimensions
47 $srcWidth = $image->getWidth( $params['page'] );
48 $srcHeight = $image->getHeight( $params['page'] );
49
50 $params['physicalWidth'] = $this->getSteppedThumbWidth(
51 $image, $params['physicalWidth'], $srcWidth, $srcHeight
52 );
53 $params['physicalHeight'] = File::scaleHeight( $srcWidth, $srcHeight, $params['physicalWidth'] );
54
55 # Don't make an image bigger than the source
56 if ( $params['physicalWidth'] >= $srcWidth ) {
57 $params['physicalWidth'] = $srcWidth;
58 $params['physicalHeight'] = $srcHeight;
59
60 # Skip scaling limit checks if no scaling is required
61 # due to requested size being bigger than source.
62 if ( !$image->mustRender() ) {
63 return true;
64 }
65 }
66
67 return true;
68 }
69
82 public function extractPreRotationDimensions( $params, $rotation ) {
83 if ( $rotation === 90 || $rotation === 270 ) {
84 // We'll resize before rotation, so swap the dimensions again
85 $width = $params['physicalHeight'];
86 $height = $params['physicalWidth'];
87 } else {
88 $width = $params['physicalWidth'];
89 $height = $params['physicalHeight'];
90 }
91
92 return [ $width, $height ];
93 }
94
109 public function doTransform( $image, $dstPath, $dstUrl, $params, $flags = 0 ) {
110 if ( !$this->normaliseParams( $image, $params ) ) {
111 return new TransformParameterError( $params );
112 }
113
114 // Create a parameter array to pass to the scaler
115 $scalerParams = [
116 // The size to which the image will be resized
117 'physicalWidth' => $params['physicalWidth'],
118 'physicalHeight' => $params['physicalHeight'],
119 'physicalDimensions' => "{$params['physicalWidth']}x{$params['physicalHeight']}",
120 // The size of the image on the page
121 'clientWidth' => $params['width'],
122 'clientHeight' => $params['height'],
123 // Comment as will be added to the Exif of the thumbnail
124 'comment' => isset( $params['descriptionUrl'] )
125 ? "File source: {$params['descriptionUrl']}"
126 : '',
127 // Properties of the original image
128 'srcWidth' => $image->getWidth(),
129 'srcHeight' => $image->getHeight(),
130 'mimeType' => $image->getMimeType(),
131 'dstPath' => $dstPath,
132 'dstUrl' => $dstUrl,
133 'interlace' => $params['interlace'] ?? false,
134 ];
135
136 if ( isset( $params['quality'] ) && $params['quality'] === 'low' ) {
137 $scalerParams['quality'] = 30;
138 }
139
140 // For subclasses that might be paged.
141 if ( $image->isMultipage() && isset( $params['page'] ) ) {
142 $scalerParams['page'] = (int)$params['page'];
143 }
144
145 # Determine scaler type
146 $scaler = $this->getScalerType( $dstPath );
147
148 if ( is_array( $scaler ) ) {
149 $scalerName = get_class( $scaler[0] );
150 } else {
151 $scalerName = $scaler;
152 }
153
154 wfDebug( __METHOD__ . ": creating {$scalerParams['physicalDimensions']} " .
155 "thumbnail of {$image->getPath()} at $dstPath using scaler $scalerName" );
156
157 if ( !$image->mustRender() &&
158 $scalerParams['physicalWidth'] == $scalerParams['srcWidth']
159 && $scalerParams['physicalHeight'] == $scalerParams['srcHeight']
160 && !isset( $scalerParams['quality'] )
161 ) {
162 # normaliseParams (or the user) wants us to return the unscaled image
163 wfDebug( __METHOD__ . ": returning unscaled image" );
164
165 return $this->getClientScalingThumbnailImage( $image, $params );
166 }
167
168 if ( $scaler === 'client' ) {
169 # Client-side image scaling, use the source URL
170 # Using the destination URL in a TRANSFORM_LATER request would be incorrect
171 return $this->getClientScalingThumbnailImage( $image, $params );
172 }
173
174 if ( $image->isTransformedLocally() && !$this->isImageAreaOkForThumbnaling( $image, $params ) ) {
175 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
176 return new TransformTooBigImageAreaError( $params, $maxImageArea );
177 }
178
179 if ( $flags & self::TRANSFORM_LATER ) {
180 wfDebug( __METHOD__ . ": Transforming later per flags." );
181 return new ThumbnailImage( $image, $dstUrl, false, $params );
182 }
183
184 # Try to make a target path for the thumbnail
185 if ( !wfMkdirParents( dirname( $dstPath ), null, __METHOD__ ) ) {
186 wfDebug( __METHOD__ . ": Unable to create thumbnail destination " .
187 "directory, falling back to client scaling" );
188
189 return $this->getClientScalingThumbnailImage( $image, $params );
190 }
191
192 # Transform functions and binaries need a FS source file
193 $thumbnailSource = $this->getThumbnailSource( $image, $params );
194
195 // If the source isn't the original, disable EXIF rotation because it's already been applied
196 if ( $scalerParams['srcWidth'] != $thumbnailSource['width']
197 || $scalerParams['srcHeight'] != $thumbnailSource['height'] ) {
198 $scalerParams['disableRotation'] = true;
199 }
200
201 $scalerParams['srcPath'] = $thumbnailSource['path'];
202 $scalerParams['srcWidth'] = $thumbnailSource['width'];
203 $scalerParams['srcHeight'] = $thumbnailSource['height'];
204
205 if ( $scalerParams['srcPath'] === false ) { // Failed to get local copy
206 wfDebugLog( 'thumbnail',
207 sprintf( 'Thumbnail failed on %s: could not get local copy of "%s"',
208 wfHostname(), $image->getName() ) );
209
210 return new MediaTransformError( 'thumbnail_error',
211 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
212 wfMessage( 'filemissing' )
213 );
214 }
215
216 // Try a hook. Called "Bitmap" for historical reasons.
218 $mto = null;
219 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
220 ->onBitmapHandlerTransform( $this, $image, $scalerParams, $mto );
221 if ( $mto !== null ) {
222 wfDebug( __METHOD__ . ": Hook to BitmapHandlerTransform created an mto" );
223 $scaler = 'hookaborted';
224 }
225
226 // $scaler will return a MediaTransformError on failure, or false on success.
227 // If the scaler is successful, it will have created a thumbnail at the destination
228 // path.
229 if ( is_array( $scaler ) && is_callable( $scaler ) ) {
230 // Allow subclasses to specify their own rendering methods.
231 $err = $scaler( $image, $scalerParams );
232 } else {
233 switch ( $scaler ) {
234 case 'hookaborted':
235 # Handled by the hook above
236 $err = $mto->isError() ? $mto : false;
237 break;
238 case 'im':
239 $err = $this->transformImageMagick( $image, $scalerParams );
240 break;
241 case 'custom':
242 $err = $this->transformCustom( $image, $scalerParams );
243 break;
244 case 'imext':
245 $err = $this->transformImageMagickExt( $image, $scalerParams );
246 break;
247 case 'gd':
248 default:
249 $err = $this->transformGd( $image, $scalerParams );
250 break;
251 }
252 }
253
254 // Remove the file if a zero-byte thumbnail was created, or if there was an error
255 // @phan-suppress-next-line PhanTypeMismatchArgument Relaying on bool/int conversion to cast objects correct
256 $removed = $this->removeBadFile( $dstPath, (bool)$err );
257 if ( $err ) {
258 // transform returned MediaTransformError
259 return $err;
260 }
261
262 if ( $removed ) {
263 // Thumbnail was zero-byte and had to be removed
264 return new MediaTransformError( 'thumbnail_error',
265 $scalerParams['clientWidth'], $scalerParams['clientHeight'],
266 wfMessage( 'unknown-error' )
267 );
268 }
269
270 if ( $mto ) {
271 // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
272 return $mto;
273 }
274
275 return new ThumbnailImage( $image, $dstUrl, $dstPath, $params );
276 }
277
285 protected function getThumbnailSource( $file, $params ) {
286 return $file->getThumbnailSource( $params );
287 }
288
310 abstract protected function getScalerType( $dstPath, $checkDstPath = true );
311
323 protected function getClientScalingThumbnailImage( $image, $params ) {
324 $url = $image->modifyClientThumbUrl( $image->getUrl(), $params );
325 return new ThumbnailImage( $image, $url, null, $params );
326 }
327
339 protected function transformImageMagick( $image, $params ) {
340 return $this->getMediaTransformError( $params, "Unimplemented" );
341 }
342
354 protected function transformImageMagickExt( $image, $params ) {
355 return $this->getMediaTransformError( $params, "Unimplemented" );
356 }
357
369 protected function transformCustom( $image, $params ) {
370 return $this->getMediaTransformError( $params, "Unimplemented" );
371 }
372
380 public function getMediaTransformError( $params, $errMsg ) {
381 return new MediaTransformError( 'thumbnail_error', $params['clientWidth'],
382 $params['clientHeight'], $errMsg );
383 }
384
395 protected function transformGd( $image, $params ) {
396 return $this->getMediaTransformError( $params, "Unimplemented" );
397 }
398
405 protected function escapeMagickProperty( $s ) {
406 // Double the backslashes
407 $s = str_replace( '\\', '\\\\', $s );
408 // Double the percents
409 $s = str_replace( '%', '%%', $s );
410 // Escape initial - or @
411 if ( strlen( $s ) > 0 && ( $s[0] === '-' || $s[0] === '@' ) ) {
412 $s = '\\' . $s;
413 }
414
415 return $s;
416 }
417
434 protected function escapeMagickInput( $path, $scene = false ) {
435 # Die on initial metacharacters (caller should prepend path)
436 $firstChar = substr( $path, 0, 1 );
437 if ( $firstChar === '~' || $firstChar === '@' ) {
438 throw new InvalidArgumentException( __METHOD__ . ': cannot escape this path name' );
439 }
440
441 # Escape glob chars
442 $path = preg_replace( '/[*?\[\]{}]/', '\\\\\0', $path );
443
444 return $this->escapeMagickPath( $path, $scene );
445 }
446
454 protected function escapeMagickOutput( $path, $scene = false ) {
455 $path = str_replace( '%', '%%', $path );
456
457 return $this->escapeMagickPath( $path, $scene );
458 }
459
468 protected function escapeMagickPath( $path, $scene = false ) {
469 # Die on format specifiers (other than drive letters). The regex is
470 # meant to match all the formats you get from "convert -list format"
471 if ( preg_match( '/^([a-zA-Z0-9-]+):/', $path, $m ) ) {
472 if ( wfIsWindows() && is_dir( $m[0] ) ) {
473 // OK, it's a drive letter
474 // ImageMagick has a similar exception, see IsMagickConflict()
475 } else {
476 throw new InvalidArgumentException( __METHOD__ . ': unexpected colon character in path name' );
477 }
478 }
479
480 # If there are square brackets, add a do-nothing scene specification
481 # to force a literal interpretation
482 if ( $scene === false ) {
483 if ( str_contains( $path, '[' ) ) {
484 $path .= '[0--1]';
485 }
486 } else {
487 $path .= "[$scene]";
488 }
489
490 return $path;
491 }
492
499 protected function getMagickVersion() {
500 $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
501 $method = __METHOD__;
502 return $cache->getWithSetCallback(
503 $cache->makeGlobalKey( 'imagemagick-version' ),
504 $cache::TTL_HOUR,
505 static function () use ( $method ) {
506 $imageMagickConvertCommand = MediaWikiServices::getInstance()
507 ->getMainConfig()->get( MainConfigNames::ImageMagickConvertCommand );
508
509 wfDebug( $method . ": Running convert -version" );
510 $return = Shell::command( $imageMagickConvertCommand, '-version' )
511 ->includeStderr()
512 ->execute()
513 ->getStdout() ?? '';
514 $x = preg_match(
515 '/Version: ImageMagick ([0-9]*\.[0-9]*\.[0-9]*)/', $return, $matches
516 );
517 if ( $x != 1 ) {
518 wfDebug( $method . ": ImageMagick version check failed" );
519 return false;
520 }
521
522 return $matches[1];
523 }
524 );
525 }
526
534 public function canRotate() {
535 return false;
536 }
537
546 public function autoRotateEnabled() {
547 return false;
548 }
549
562 public function rotate( $file, $params ) {
563 return new MediaTransformError( 'thumbnail_error', 0, 0,
564 static::class . ' rotation not implemented' );
565 }
566
575 public function mustRender( $file ) {
576 return $this->canRotate() && $this->getRotation( $file ) != 0;
577 }
578
590 public function isImageAreaOkForThumbnaling( $file, &$params ) {
591 $maxImageArea = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::MaxImageArea );
592
593 # For historical reasons, hook starts with BitmapHandler
594 $checkImageAreaHookResult = null;
595 ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onBitmapHandlerCheckImageArea(
596 $file, $params, $checkImageAreaHookResult );
597
598 if ( $checkImageAreaHookResult !== null ) {
599 // was set by hook, so return that value
600 return (bool)$checkImageAreaHookResult;
601 }
602
603 if ( $maxImageArea === false ) {
604 // Checking is disabled, fine to thumbnail
605 return true;
606 }
607
608 $srcWidth = $file->getWidth( $params['page'] );
609 $srcHeight = $file->getHeight( $params['page'] );
610
611 if ( $srcWidth * $srcHeight > $maxImageArea
612 && !( $file->getMimeType() === 'image/jpeg'
613 && $this->getScalerType( null, false ) === 'im' )
614 ) {
615 # Only ImageMagick can efficiently downsize jpg images without loading
616 # the entire file in memory
617 return false;
618 }
619 return true;
620 }
621}
622
624class_alias( TransformationalImageHandler::class, 'TransformationalImageHandler' );
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.
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.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
const ImageMagickConvertCommand
Name constant for the ImageMagickConvertCommand setting, for use with Config::get()
const MaxImageArea
Name constant for the MaxImageArea setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Media handler abstract base class for images.
getSteppedThumbWidth(File $image, int $requestWidth, int $srcWidth, int $srcHeight)
Adjust the thumbnail size to fit the width steps defined in config via $wgThumbnailSteps.
removeBadFile( $dstPath, $retval=0)
Check for zero-sized thumbnails.
getRotation( $file)
On supporting image formats, try to read out the low-level orientation of the file and return the ang...
Basic media transform error class.
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.
getMediaTransformError( $params, $errMsg)
Get a MediaTransformError with error 'thumbnail_error'.
canRotate()
Returns whether the current scaler supports rotation.
transformCustom( $image, $params)
Transform an image using a custom command.
escapeMagickProperty( $s)
Escape a string for ImageMagick's property input (e.g.
escapeMagickInput( $path, $scene=false)
Escape a string for ImageMagick's input filenames.
isImageAreaOkForThumbnaling( $file, &$params)
Check if the file is smaller than the maximum image area for thumbnailing.
getMagickVersion()
Retrieve the version of the installed ImageMagick You can use PHPs version_compare() to use this valu...
transformImageMagickExt( $image, $params)
Transform an image using the Imagick PHP extension.
getScalerType( $dstPath, $checkDstPath=true)
Returns what sort of scaler type should be used.
transformImageMagick( $image, $params)
Transform an image using ImageMagick.
transformGd( $image, $params)
Transform an image using the built in GD library.
getThumbnailSource( $file, $params)
Get the source file for the transform.
autoRotateEnabled()
Should we automatically rotate an image based on exif.
escapeMagickPath( $path, $scene=false)
Armour a string against ImageMagick's GetPathComponent().
doTransform( $image, $dstPath, $dstUrl, $params, $flags=0)
Create a thumbnail.
extractPreRotationDimensions( $params, $rotation)
Extracts the width/height if the image will be scaled before rotating.
getClientScalingThumbnailImage( $image, $params)
Get a ThumbnailImage that represents an image that will be scaled client side.
mustRender( $file)
Returns whether the file needs to be rendered.
escapeMagickOutput( $path, $scene=false)
Escape a string for ImageMagick's output filename.
Executes shell commands.
Definition Shell.php:32